LINE Bot開発でのイベントハンドリングをclass-resolverでシンプルにする #LINEDC

LINE Botのイベントハンドリングを、Chain of Responsibilityパターンを実現する`class-resolver`パッケージを使ってリファクタリングする方法を紹介。イベントタイプごとにハンドラークラスを作成し、`Resolver`に登録することで、可読性の高いコードと拡張性の確保を実現。

広告ここから
広告ここまで

目次

    LINE Message APIを利用したbotアプリ開発では、さまざまなイベントタイプ(メッセージやフォローなど)をAPIで処理する必要があります。通常はif分岐で処理することが多いのですが、オブジェクト指向ライクなハンドリングをしたいときもあります。今回は、個人的にリリースしているclass-resolverパッケージを使って、LINE Botのイベントハンドラーをリファクタリングした事例を紹介します。

    課題:LINE Botのイベントハンドリング

    LINE Messaging APIを使ったBotを開発していると、様々なイベントタイプに対応する必要があります。フォローイベント、メッセージイベント、ポストバックイベントなど、多くのイベントタイプを処理する必要があります。

    一般的な実装では、以下のようなif文の連鎖になりがちです:

    if (event.type === 'follow') {
      // フォローイベントの処理
    } else if (event.type === 'message') {
      if (event.message.type === 'text') {
        // テキストメッセージの処理
      } else if (event.message.type === 'image') {
        // 画像メッセージの処理
      }
    } else if (event.type === 'postback') {
      // ポストバックイベントの処理
    } else {
      // その他のイベント
    }
    

    この実装でも初期は問題ないのですが、次第に関数が肥大化してコードの可読性が下がるリスクを抱えています。また、大きな条件分岐になりがちなため、LINE側で新しいイベントが追加された場合や、botがサポートするイベントの種類を追加する場合に、既存の動きを壊さないようにコードを変更する必要があり、開発に必要な集中力やテスト工数が大きくなる恐れもあります。同時に抽象化されていないため、テストが書きにくいという問題もありますね。

    Chain of Responsibilityパターンとは

    Chain of Responsibility(責任連鎖)パターンは、GoFのデザインパターンの一つで、リクエストの送信者と受信者を切り離すためのパターンです。このパターンでは、複数のオブジェクトがリクエストを処理する機会を持ち、リクエストを受け取ったオブジェクトは以下のいずれかを行います:

    1. リクエストを自分で処理する
    2. リクエストを次のオブジェクトに転送する

    このパターンを使うことで、どのオブジェクトがリクエストを処理するか事前に明示的に指定する必要がなくなり、オブジェクト間の結合度を低く保つことができます。

    class-resolverパッケージの紹介

    class-resolverは、TypeScriptで実装されたChain of Responsibilityパターンを簡単に実現するためのパッケージです。このパッケージを使うことで、様々なタイプのリクエストを適切なハンドラーに振り分けることが容易になります。

    インストール方法

    npm install class-resolver
    # または
    yarn add class-resolver
    

    基本的な使い方

    class-resolverの基本的な使い方は以下の通りです:

    1. ResolveTargetインターフェースを実装したハンドラークラスを作成する
    2. 各ハンドラークラスにsupportsメソッドを実装して、どのリクエストを処理できるか定義する
    3. Resolverクラスにハンドラーを登録する
    4. リクエストを処理する際にresolveメソッドを呼び出して適切なハンドラーを取得する

    import { Resolver, ResolveTarget } from 'class-resolver';
    
    // ハンドラーのインターフェース
    interface MyHandler extends ResolveTarget<[string], void> {
      supports(type: string): boolean;
      handle(data: string): void;
    }
    
    // 具体的なハンドラー実装
    class Handler1 implements MyHandler {
      supports(type: string): boolean {
        return type === 'type1';
      }
      
      handle(data: string): void {
        console.log('Handler1 is processing:', data);
      }
    }
    
    // Resolverの設定と使用
    const resolver = new Resolver<MyHandler>(
      new Handler1(),
      // 他のハンドラーを追加...
    );
    
    // 使用例
    const handler = resolver.resolve('type1');
    handler.handle('some data');
    

    実装例:LINE Botへの適用

    それでは、class-resolverを使ってLINE Botのイベントハンドリングをリファクタリングしてみましょう。

    1. ベースとなるインターフェースの定義

    まず、全てのイベントハンドラーが実装すべきインターフェースを定義します:

    import { WebhookEvent } from '@line/bot-sdk';
    import { ResolveTarget } from 'class-resolver';
    
    export interface LineEventHandler extends ResolveTarget<[WebhookEvent], Promise<void>> {
      supports(type: string): boolean;
      handle(event: WebhookEvent): Promise<void>;
    }
    

    2. 各イベントタイプに対応するハンドラーの実装

    次に、各イベントタイプに対応するハンドラーを実装します。

    フォローイベントのハンドラーの場合、supportsにてフォローイベントだけをtrueにする処理を書きましょう。

    import { WebhookEvent } from '@line/bot-sdk';
    import { LineEventHandler } from './interfaces';
    
    export class FollowEventHandler implements LineEventHandler {
      supports(type: string): boolean {
        return type === 'follow';
      }
      
      async handle(event: WebhookEvent): Promise<void> {
        if (event.type !== 'follow') return;
        
        // フォローイベントの処理をここに実装
        console.log('フォローされました!', event.source.userId);
        
        // 例: ウェルカムメッセージを送信
        // await this.client.replyMessage(event.replyToken, {
        //   type: 'text',
        //   text: 'フォローありがとうございます!'
        // });
      }
    }
    

    メッセージイベントも同様です。ただしこちらはメッセージタイプによる分岐がハンドラー内に発生します。supportsメソッド内でメッセージタイプを利用した条件分岐を追加することも可能ですが、その場合はsupportsに渡すデータの量が多くなることに注意が必要です。

    import { Client, WebhookEvent } from '@line/bot-sdk';
    import { LineEventHandler } from './interfaces';
    
    export class MessageEventHandler implements LineEventHandler {
      constructor(private readonly client: Client) {}
      
      supports(type: string): boolean {
        return type === 'message';
      }
      
      async handle(event: WebhookEvent): Promise<void> {
        if (event.type !== 'message') return;
        
        // メッセージタイプによる分岐
        switch (event.message.type) {
          case 'text':
            await this.handleTextMessage(event);
            break;
          case 'image':
            await this.handleImageMessage(event);
            break;
          // 他のメッセージタイプも同様に処理
        }
      }
      
      private async handleTextMessage(event: WebhookEvent): Promise<void> {
        if (event.type !== 'message' || event.message.type !== 'text') return;
        
        const { text } = event.message;
        console.log('テキストメッセージを受信:', text);
        
        // メッセージ内容に応じた処理
        if (text.includes('こんにちは')) {
          await this.client.replyMessage(event.replyToken, {
            type: 'text',
            text: 'こんにちは!何かお手伝いできることはありますか?'
          });
        }
      }
      
      private async handleImageMessage(event: WebhookEvent): Promise<void> {
        if (event.type !== 'message' || event.message.type !== 'image') return;
        
        // 画像メッセージの処理
        console.log('画像を受信しました');
      }
    }
    

    ポストバックイベントも同様ですね。この辺りについては、class-resolverをネストさせることも検討する必要があるかもしれません。薄いライブラリなので、多少ネストさせても直ちにパフォーマンスへ影響が出ることは少ないかと思います。

    import { Client, WebhookEvent } from '@line/bot-sdk';
    import { LineEventHandler } from './interfaces';
    
    export class PostbackEventHandler implements LineEventHandler {
      constructor(private readonly client: Client) {}
      
      supports(type: string): boolean {
        return type === 'postback';
      }
      
      async handle(event: WebhookEvent): Promise<void> {
        if (event.type !== 'postback') return;
        
        // ポストバックデータの解析
        const { data } = event.postback;
        console.log('ポストバックデータ:', data);
        
        // データに応じた処理
        if (data.startsWith('action=')) {
          const action = data.replace('action=', '');
          await this.handleAction(event, action);
        }
      }
      
      private async handleAction(event: WebhookEvent, action: string): Promise<void> {
        // アクションに応じた処理
        switch (action) {
          case 'showMenu':
            await this.client.replyMessage(event.replyToken, {
              type: 'text',
              text: 'メニューを表示します'
            });
            break;
          // 他のアクションも同様に処理
        }
      }
    }
    

    3. Resolverの設定とイベント処理

    最後に、メインのWebhookハンドラーでclass-resolverを使用してイベントをハンドリングします。

    import { Client, middleware, WebhookEvent } from '@line/bot-sdk';
    import { Resolver } from 'class-resolver';
    import { FollowEventHandler, MessageEventHandler, PostbackEventHandler } from './handlers';
    import { LineEventHandler } from './interfaces';
    
    export const webhookHandler = async (req, res) => {
      // LINE Clientの初期化
      const client = new Client({
        channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
        channelSecret: process.env.LINE_CHANNEL_SECRET
      });
      
      // イベントハンドラーの初期化
      const resolver = new Resolver<LineEventHandler>(
        new FollowEventHandler(),
        new MessageEventHandler(client),
        new PostbackEventHandler(client)
      );
      
      // Webhookイベントの処理
      const events: WebhookEvent[] = req.body.events;
      await Promise.all(
        events.map(async (event) => {
          try {
            const handler = resolver.resolve(event.type);
            await handler.handle(event);
          } catch (error) {
            console.error(`Unsupported event type: ${event.type}`, error);
          }
        })
      );
      
      return res.status(200).json({ status: 'ok' });
    };
    

    このようにすることで、Webhookハンドラー側では、利用するハンドラークラスを追加する以外の変更が不要になります。

    リファクタリング前後の比較

    それではリファクタリング前後を簡単に比較してみましょう。

    Before: 条件分岐による実装

    条件分岐による従来の実装では、ネストしたif文による大きな条件分岐が発生します。1つ2つのイベントをサポートするのみであれば問題ないのですが、リッチメニューやポストバックイベントのサポート、メッセージタイプによる処理の変化まで検討し始めると、どこかで抽象化が必要になるでしょう。

    export const webhookHandler = async (req, res) => {
      const client = new Client({
        channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
        channelSecret: process.env.LINE_CHANNEL_SECRET
      });
      
      const events: WebhookEvent[] = req.body.events;
      await Promise.all(
        events.map(async (event) => {
          if (event.type === 'follow') {
            // フォローイベントの処理
            console.log('フォローされました!', event.source.userId);
            await client.replyMessage(event.replyToken, {
              type: 'text',
              text: 'フォローありがとうございます!'
            });
          } else if (event.type === 'message') {
            if (event.message.type === 'text') {
              // テキストメッセージの処理
              const { text } = event.message;
              console.log('テキストメッセージを受信:', text);
              
              if (text.includes('こんにちは')) {
                await client.replyMessage(event.replyToken, {
                  type: 'text',
                  text: 'こんにちは!何かお手伝いできることはありますか?'
                });
              }
            } else if (event.message.type === 'image') {
              // 画像メッセージの処理
              console.log('画像を受信しました');
            }
          } else if (event.type === 'postback') {
            // ポストバックイベントの処理
            const { data } = event.postback;
            console.log('ポストバックデータ:', data);
            
            if (data.startsWith('action=')) {
              const action = data.replace('action=', '');
              if (action === 'showMenu') {
                await client.replyMessage(event.replyToken, {
                  type: 'text',
                  text: 'メニューを表示します'
                });
              }
            }
          }
        })
      );
      
      return res.status(200).json({ status: 'ok' });
    };
    

    After: Chain of Responsibilityパターンによる実装

    前述のハンドラークラスとResolverを使った実装に比べ、メインのWebhookハンドラーがシンプルになっています。各イベントタイプの処理ロジックは個別のクラスに分離され、より管理しやすくなっています。

    import { Client, middleware, WebhookEvent } from '@line/bot-sdk';
    import { Resolver } from 'class-resolver';
    import { FollowEventHandler, MessageEventHandler, PostbackEventHandler } from './handlers';
    import { LineEventHandler } from './interfaces';
    
    export const webhookHandler = async (req, res) => {
      // LINE Clientの初期化
      const client = new Client({
        channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
        channelSecret: process.env.LINE_CHANNEL_SECRET
      });
      
      // イベントハンドラーの初期化
      const resolver = new Resolver<LineEventHandler>(
        new FollowEventHandler(),
        new MessageEventHandler(client),
        new PostbackEventHandler(client)
      );
      
      // Webhookイベントの処理
      const events: WebhookEvent[] = req.body.events;
      await Promise.all(
        events.map(async (event) => {
          try {
            const handler = resolver.resolve(event.type);
            await handler.handle(event);
          } catch (error) {
            console.error(`Unsupported event type: ${event.type}`, error);
          }
        })
      );
      
      return res.status(200).json({ status: 'ok' });
    };
    

    実装時の注意点とトラブルシューティング

    1. ハンドラーの順序

    Resolverクラスにハンドラーを登録する際の順序は重要です。resolveメソッドは登録された順にsupportsメソッドを呼び出し、最初にtrueを返したハンドラーを使用します。特に、複数のハンドラーが同じタイプのイベントをサポートする場合は注意が必要です。

    2. タイプガードの活用

    TypeScriptのタイプガードを活用して、型安全性を確保しましょう。例えば:

    async handle(event: WebhookEvent): Promise<void> {
      // タイプガードを使用して型安全性を確保
      if (event.type !== 'message' || event.message.type !== 'text') return;
      
      // ここではevent.message.textが安全に使用可能
      const { text } = event.message;
      // ...
    }
    

    3. エラーハンドリング

    resolveメソッドは適切なハンドラーが見つからない場合に例外をスローします。この例外をキャッチして適切に処理することで、システムの安定性を確保できます。

    try {
      const handler = resolver.resolve(event.type);
      await handler.handle(event);
    } catch (error) {
      console.error(`Unsupported event type: ${event.type}`, error);
      // 必要に応じてエラーレポートを送信するなどの処理
    }
    

    4. 依存性の注入

    ハンドラークラスが外部サービスやクライアントに依存する場合は、コンストラクタインジェクションを使用して依存性を注入することをお勧めします。これにより、テスト時にモックを注入することが容易になります。

    export class MessageEventHandler implements LineEventHandler {
      constructor(
        private readonly client: Client,
        private readonly logger: Logger // 追加の依存性
      ) {}
      
      // ...
    }
    

    リファクタリングの利点

    このリファクタリングにより、以下のような利点が得られました:

    1. コードの整理: 各イベントタイプのハンドリングロジックが独立したクラスに分離され、より管理しやすくなりました。
    2. 拡張性の向上: 新しいイベントタイプを追加する場合、新しいハンドラークラスを作成し、resolverに追加するだけで対応できます。
    3. 責務の分離: 各ハンドラーが特定のイベントタイプの処理に特化し、単一責任の原則に従っています。
    4. テスタビリティの向上: 各ハンドラーを個別にテストできるようになりました。
    5. コードの再利用性: ハンドラークラスは他のプロジェクトでも再利用できます。

    まとめ

    class-resolverを使用することで、LINE Botのイベントハンドリングをよりクリーンでメンテナンスしやすいコードに改善できました。Chain of Responsibilityパターンの適用により、コードの拡張性と保守性が大幅に向上しています。

    このパターンは、LINE Bot以外のイベントハンドリングでも活用できます。例えば、Webアプリケーションのルーティング、コマンドの処理、通知システムなど、条件分岐が複雑になりがちな場面で効果を発揮します。

    また、TypeScriptのジェネリクスを活用することで、型安全性も確保できています。これにより、開発時のエラーを早期に発見できるようになりました。

    参考リンク


    この記事が皆さんのコード改善の参考になれば幸いです。質問やフィードバックがありましたら、GitHub Issuesにお寄せください。また、実際のプロジェクトでの活用事例も共有いただけると嬉しいです。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark