ドメイン駆動設計を実践して自分の LINE 環境をリファクタリングしてみた(実装編2)

間が空いてしまいましたが、Lambda リファクタリングの最終回です。空いてしまったのはこのあともこの環境を使おうかと GitHub の環境構築に手間取ってしまったためでして。
2024.01.29

こんにちは、高崎@アノテーションです。

はじめに

の続きになります。

残りのソースをそれぞれ書いて説明していこうかと思いましたが、記事が長々となってしまったため GitHub に置きました。 1

今回の記事では clone 後の利用方法と、各機能についてを実装したことを記載しますが記事内では抜粋して記載しますので詳細は GitHub のソースをご参照ください。

なお、今回もお約束として正確性としては程遠い可能性もあり、エラーチェックも甘い実装例であることを予めご了承ください。

利用方法

事前に Node.js(& npm)と aws-cdk をインストールしてください。

初回

上記の GitHub を clone 後、以下を行ってください。

なおデプロイには AWS への AssumeRole を付与する必要があります。

npm ci
cd src/lambda
npm ci
cd ../..
npm run build
cdk synth
cdk bootstrap
cdk deploy

二回目以降

ソースを修正や追記を行った場合cdk desroyしなければ二回目以降は以下でビルドとデプロイが可能です。

npm run build
cdk deploy

次章より、前回の続きとして実装について軽く説明したいと思います。

imageCraft の実装

基本的な考え方

下記のクラス図における赤枠の箇所です。

当初、DynamoDB に生成 AI に作らせたコマンドと生成画像の URL を保存しようかとも考えましたが見送り、以下のように整理しました。

  • 元の実装から画像生成と URL 取得の 2 つがあるため interface にこの 2 つを定義する
  • 実装クラスはこの interface から派生して定義する
  • コンストラクタには Bedrock、S3 のサービスクラスのインスタンスと必要なパラメータを提供する
  • DI コンテナは以下のようにまとめる
    • S3 のバケット名は既にある環境変数からバインド
    • imageCraft のリポジトリに Bedrock、S3、S3 に保存するバケット名をバインド
    • Bedrock、S3 の生成に必要なリージョンは既存の DynamoDB と同様のものを取得

ドメインモデルとインフラストラクチャのコード

外部提供するドメイン定義

記事が長くなるのでたたみました。

domain/model/imageCraft/imageCraft-repository.ts

export interface CreateImageProc {
  orderedText: string;
  quoteToken: string;
}

export interface ImageCraftRepository {
  createImage(param: CreateImageProc): Promise<void>;
  getImageUrl(quoteToken: string): Promise<string>;
}

ソースは下記にもあります。

インフラストラクチャ実体

記事が長くなるのでたたみました。

infrastracture/repository/imageCraft-bedrock-s3-repository.ts

import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { ImageCraftRepository, CreateImageProc } from "../../domain/model/imageCraft/imageCraft-repository";

export class ImageCraftRepositoryImpl implements ImageCraftRepository {
  private readonly bedrockClient: BedrockRuntimeClient;
  private readonly s3Client: S3Client;
  private readonly bucketName: string;
  private readonly paramCfgScale: number;
  private readonly paramSeed: number;
  private readonly paramSteps: number;

  constructor({
    bedrock,
    s3,
    bucketName,
  }: {
    bedrock: BedrockRuntimeClient,
    s3: S3Client,
    bucketName: string
  }) {
    this.bedrockClient = bedrock;
    this.s3Client = s3;
    this.bucketName = bucketName;
    this.paramCfgScale = Number(process.env.BEDROCK_PARAM_CFG_SCALE) || 10;
    this.paramSeed = Number(process.env.BEDROCK_PARAM_SEED) || 0;
    this.paramSteps = Number(process.env.BEDROCK_PARAM_STEPS) || 50;
  }

  /**
   * 画像取得
   * @param orderedText 生成AIへ要求するテキスト 
   * @returns 生成画像の base64 エンコードテキスト
   */
  private async getAutoImage(orderedText: string): Promise<string> {
    const invokeBedrock = new InvokeModelCommand({
      modelId: "stability.stable-diffusion-xl-v0",
      body: JSON.stringify({
        text_prompts: [{ text: orderedText }],
        cfg_scale: this.paramCfgScale,
        seed: this.paramSeed,
        steps: this.paramSteps,
      }),
      accept: "application/json",
      contentType: "application/json",
    });
    console.log(invokeBedrock);
    const responseBedrock = await this.bedrockClient.send(invokeBedrock);
    const jsonBedrock = JSON.parse(Buffer.from(responseBedrock.body).toString("utf-8"));
    return jsonBedrock.artifacts[0].base64;
  }

  /**
   * 画像を S3 へ保存する
   * @param imagePlaneText 画像のプレーンテキスト
   * @param quoteToken LINE の quoteToken(ファイル名に使用する)
   */
  private async saveImageToS3(imagePlaneText: string, quoteToken: string): Promise<void> {
    const rawData = Buffer.from(imagePlaneText, "base64");
    const inputCommand = {
      /*ACL: "public-read",*/
      Body: rawData,
      Bucket: this.bucketName,
      Key: `${quoteToken}.png`,
      ContentType: "image/png",
    };
    const commandS3 = new PutObjectCommand(inputCommand);
    await this.s3Client.send(commandS3);
  }

  /**
   * 生成 AI に画像生成を要求して S3 に格納
   * @param parameter 生成要求パラメータ
   */
  async createImage(parameter: CreateImageProc): Promise<void> {
    try {
      const planeTextImage = await this.getAutoImage(parameter.orderedText);
      await this.saveImageToS3(planeTextImage, parameter.quoteToken);
    } catch (e) {
      console.error(e);
    }
  }

  /**
   * 生成された画像の URL を取得
   * @param quoteToken ファイル名のキーになる quoteToken
   * @returns URL 文字列
   */
  async getImageUrl(quoteToken: string): Promise<string> {
    const command = new GetObjectCommand({
      Bucket: this.bucketName,
      Key: `${quoteToken}.png`,
    })
    return getSignedUrl(this.s3Client, command);
  }
}

ソースは下記にもあります。

LINE Messaging API 実行部の実装

基本的な考え方

LINE Messaging API 実行を扱う箇所については、以下のように整理しました。

  • LINE に関わる以下の処理をクラスに設ける
    • リプライ送信
    • 署名のバリデーションチェック
  • パラメータは LINE bot-sdk で用意しているクラス以外の 3rd パーティの定義型は使用しない
  • DI コンテナにクラスを構築する仕組みを埋め込む
  • 構築に必要なパラメータ(アクセストークンとチャンネルシークレット)は Systems Manager から取得する
  • 取得は非同期処理のため、DI コンテナに非同期の処理を埋め込むようにする

ドメインとインフラストラクチャのコード

外部提供するドメイン定義

記事が長くなるのでたたみました。

domain/support/line-bot/line-bot.ts

import { messagingApi } from "@line/bot-sdk";

export type LineReplyMessageResult = void;
export type LineReplyMessageParameter = {
  replyToken: string;
  messages: messagingApi.Message[];
}
export interface LineBot {
  checkSignature(body: string, signature: string): boolean;
  replyMessage(replyMessage: LineReplyMessageParameter): Promise<LineReplyMessageResult>;
}

ソースは下記にもあります。

インフラストラクチャ実体

記事が長くなるのでたたみました。
import { messagingApi, validateSignature } from "@line/bot-sdk";
import { LineBot, LineReplyMessageParameter, LineReplyMessageResult } from "../../domain/support/line-bot/line-bot";

interface LineParameterOptions {
  accessToken: string;
  channelSecret: string;
}
/**
 * Line-Bot 実行インプリメント
 */
export class LineBotImpl implements LineBot {
  private readonly options: LineParameterOptions;
  private readonly apiClient: messagingApi.MessagingApiClient;
  /* MessagingApiBlobClient を使用するときは有効にすること
  private readonly apiClientBlob: messagingApi.MessagingApiBlobClient;
  */

  constructor(lineApiParameteroptionss: LineParameterOptions) {
    this.options = lineApiParameteroptionss;
    this.apiClient = new messagingApi.MessagingApiClient({ channelAccessToken: this.options.accessToken });
    /* MessagingApiBlobClient を使用するときは有効にすること
    this.apiClientBlob = new messagingApi.MessagingApiBlobClient({ channelAccessToken: this.options.accessToken });
    */
  }

  /**
   * LINE ヘッダバリデーションラッパー
   * @param lineApiParameter 判定用に使うチャネルシークレット
   * @param param APIGatewayProxyEvent クラス
   * @returns line-bot.validateSignature 戻り値
   */
  checkSignature(body: string, signature: string): boolean {
    return validateSignature(
      body,
      this.options.channelSecret,
      signature,
    );
  }

  /**
   * replyMessage ラッパー
   * @param replyMessage 返信メッセージ
   * @returns void
   */
  async replyMessage(replyMessage: LineReplyMessageParameter): Promise<LineReplyMessageResult> {
    await this.apiClient.replyMessage({
      replyToken: replyMessage.replyToken,
      messages: replyMessage.messages,
    });
    return undefined
  }
}

ソースは下記にもあります。

ユースケース層

基本的な考え方

ユースケースのイメージは下記になります(初回の画像の再掲)。

ユースケース層は以下のように整理しました。

  • 以下で構成する
    • ハンドラから呼ばれる実行部
    • コマンドディスパッチャ
  • ハンドラから呼ばれる実行部は以下のような処理構成とする
    • 処理に必要な以下の処理は DI コンテナで定義する際に用意して設定しておく
    • LINE Messaging API Client 実行部
    • ImageCraft リポジトリ
    • MemoStores リポジトリ
    • 引数は SDK に依存する型ではなく body と header 署名を string 型でハンドラから渡してもらう
    • body は LINE Bot のWebhookRequestBodyであるためパースし、event 配列をmapで回して各イベント処理関数を呼ぶ
    • イベント処理関数にてコマンドを判断してディスパッチャ部で定義した実行を行う関数を呼ぶ
  • ディスパッチャ部はそれぞれのコマンド実行を担う

ユースケースのコード

以下のようにしました。

記事が長くなるのでたたみました。

use-case/line-bot-use-case/use-case.ts

import { WebhookRequestBody, WebhookEvent } from "@line/bot-sdk";
import { LineBot } from "../../domain/support/line-bot/line-bot";
import { MemoStoreRepository } from "../../domain/model/memoStore/memoStore-repository";
import { ImageCraftRepository } from "../../domain/model/imageCraft/imageCraft-repository";
import { execRegisterCommand, execListCommand, execDeleteCommand, execAskCommand, ReplyMessages } from "./dispatchCommand";

export class InvalidSignatureError extends Error {}
export class InvalidRequestError extends Error {}
export class UnexpectedError extends Error {}
export type LineBotUseCaseResult = 
  | void
  | InvalidSignatureError
  | InvalidRequestError
  | UnexpectedError;
export type ExecWebhookEventResult =
  | void
  | UnexpectedError;

/**
 * event ディスパッチ処理
 * @param webhookEvent : event インスタンス
 * @param lineBotClient : LINE の Messaging API 等実行インスタンス
 * @param memoStoreRepository : memoStore データリポジトリ
 * @param imageCraftRepository : imageCraft データリポジトリ
 * @returns ExecWebhookEventResult 戻り値(エラーインスタンス、エラー無しの場合は undefined)
 */
const dispatchEvent = async({
  webhookEvent,
  lineBotClient,
  memoStoreRepository,
  imageCraftRepository,
}: {
  webhookEvent: WebhookEvent,
  lineBotClient: LineBot,
  memoStoreRepository: MemoStoreRepository,
  imageCraftRepository: ImageCraftRepository,
}): Promise<ExecWebhookEventResult> => {
  console.log("LINE Bot use case start.", webhookEvent);
  if (webhookEvent.type !== "message" || webhookEvent.message.type !== "text") {
    console.error("メッセージがテキストではない");
    return new UnexpectedError();
  }
  const requestText: string = webhookEvent.message.text;
  const lineUserId: string = webhookEvent.source.userId || "";
  const quoteToken = webhookEvent.message.quoteToken;
  const replyToken = webhookEvent.replyToken;
  const commandResult: ReplyMessages = [];

  try {
    if (requestText.startsWith("regist:")) {
      // データ登録機能
      const resultRegisterCommand = await execRegisterCommand({
        memoStoreRepository,
        lineUserId,
        memoText: requestText.replace("regist:", ""),
        quoteToken,
      });
      console.log("result : ", resultRegisterCommand);
      resultRegisterCommand.map((item) => commandResult.push(item));
    } else if (requestText.startsWith("list")) {
      // データ一覧表示機能
      const resultListCommand = await execListCommand({
        memoStoreRepository,
        lineUserId,
        quoteToken,
        maxListNumber: Number(process.env.TABLE_MAXIMUM_NUMBER_OF_RECORD) || 5,
      });
      console.log("result : ", resultListCommand);
      resultListCommand.map((item) => commandResult.push(item));
    } else if (requestText.startsWith("delete:")) {
      // データ削除機能
      const resultDeleteCommand = await execDeleteCommand({
        memoStoreRepository,
        lineUserId,
        messageId: Number(requestText.replace("delete:", "")),
        quoteToken,
      });
      console.log("result : ", resultDeleteCommand);
      resultDeleteCommand.map((item) => commandResult.push(item));
    } else if (requestText.startsWith("ask:")) {
      // 生成 AI へ画像生成依頼機能
      const resultAskCommand = await execAskCommand({
        imageCraftRepository,
        orderedText: requestText.replace("ask:", ""),
        quoteToken,
      });
      console.log("result : ", resultAskCommand);
      resultAskCommand.map((item) => commandResult.push(item));
    } else {
      // オウム返し
      commandResult.push({
        type: "text",
        text: webhookEvent.message.text,
        quoteToken: quoteToken,
      });
    }
    console.log("commandResult : ", commandResult);
    // LINE リプライ実行
    await lineBotClient.replyMessage({
      replyToken,
      messages: commandResult,
    });
  } catch (e) {
    console.error(e);
    return new UnexpectedError();
  }
  return undefined;
}

export type LineBotUseCase = (
  stringBody?: string,
  stringSignature?: string,
) => Promise<LineBotUseCaseResult>;
/**
 * LINE ボットのユースケース実行処理
 * @param lineBotClient : LINE の Messaging API 等実行インスタンス
 * @param validateSignature : 署名検証用関数
 * @param memoStoreRepository : memoStore データリポジトリ
 * @param imageCraftRepository : imageCraft データリポジトリ
 * @returns LineBotUseCase 戻り値(エラーインスタンス、エラー無しの場合は undefined)
 */
export const execLineBotUseCase = ({
  lineBotClient,
  memoStoreRepository,
  imageCraftRepository,
}: {
  lineBotClient: LineBot,
  memoStoreRepository: MemoStoreRepository,
  imageCraftRepository: ImageCraftRepository,
}): LineBotUseCase => 
async (stringBody?: string, stringSignature?: string): Promise<LineBotUseCaseResult> => {
  if (stringBody == null) {
    return new InvalidRequestError();
  }
  if (stringSignature == null) {
    return new InvalidRequestError();
  }
  // 署名検証
  const validateResult = lineBotClient.checkSignature( stringBody, stringSignature );
  if (!validateResult) {
    return new InvalidSignatureError();
  }
  // body から必要なパラメータを取得
  const bodyRequest: WebhookRequestBody = JSON.parse(stringBody!);
  const { events } = bodyRequest;
  // event 配列ごとにディスパッチを呼ぶ(通常は 1 つしか無い)
  const results = await Promise.allSettled(
    events.map(async (webhookEvent) => {
      await dispatchEvent({
        webhookEvent,
        lineBotClient,
        memoStoreRepository,
        imageCraftRepository,
      });
    })
  );
  console.log("event results : ", results);
  // events 配列ごとに処理した結果を確認
  const errorResults = (results as PromiseFulfilledResult<ExecWebhookEventResult>[])
    .filter((result) => result.value instanceof Error);
  // 一つでもエラーがあればエラー終了
  if (errorResults.length > 0) {
    console.error("処理がエラーになりました", errorResults);
    return new UnexpectedError();
  }
  // エラーがない場合は undefined で終了
  return undefined;
}

ソースは下記にもあります。

ハンドラ部

いよいよ、ハンドラ部です。

基本的な考え方

ハンドラ部は以下のように整理しました。

  • ハンドラ部は前項のユースケースをコールする
  • 呼び出しに必要なヘッダ署名を取得するための定義LINE_SIGNATURE_HTTP_HEADER_NAMEはコンテナに保持しておく 2

ハンドラ部のコード

以下のようになりました。

記事が長くなるのでたたみました。

handler/line-bot/line-bot.ts

import { APIGatewayProxyResult, APIGatewayProxyEvent, Context } from "aws-lambda";
import { ID_LINE_BOT_USE_CASE, ID_LINE_SIGNATURE_NAME, initContainer } from "../../di-container/register-container";
import { LineBotUseCase, InvalidSignatureError, InvalidRequestError, UnexpectedError } from "../../use-case/line-bot-use-case/use-case";

// レスポンス結果を設定
const resultOK: APIGatewayProxyResult = {
  statusCode: 200,
  body: JSON.stringify({}),
};
const resultSigError: APIGatewayProxyResult = {
  statusCode: 403,
  body: JSON.stringify({}),
};
const resultReqError: APIGatewayProxyResult = {
  statusCode: 401,
  body: JSON.stringify({}),
}
const resultError: APIGatewayProxyResult = {
  statusCode: 500,
  body: JSON.stringify({}),
};

/**
 * Lambda ハンドラ
 * @param eventLambda : Lambda ハンドライベントデータ
 * @param _contextLambda : Lambda ハンドラコンテキストデータ
 * @param callback : コールバック
 * @returns APIGatewayProxyResult : レスポンス
 */
export const handler = async (
  eventLambda: APIGatewayProxyEvent,
  contextLambda: Context,
): Promise<APIGatewayProxyResult> => {
  console.log(JSON.stringify(eventLambda))
  console.log(JSON.stringify(contextLambda));

  // コンテナ初期化
  const container = initContainer();

  // コンテナ取得
  const execLineBotUseCase = await container.getAsync<LineBotUseCase>(ID_LINE_BOT_USE_CASE);
  const stringSignature = container.get<string>(ID_LINE_SIGNATURE_NAME);
  const resultUseCase = await execLineBotUseCase(eventLambda.body!, eventLambda.headers[stringSignature]);

  // エラー内容により出力結果を変える
  if (resultUseCase instanceof InvalidSignatureError) {
    console.error("署名エラーが発生");
    return resultSigError;
  }
  if (resultUseCase instanceof InvalidRequestError) {
    console.error("リクエストエラーが発生");
    return resultReqError;
  }
  if (resultUseCase instanceof UnexpectedError) {
    console.error("予期しないエラーが発生");
    return resultError;
  }
  return resultOK;
}

ソースは下記にもあります。

これでようやく Lambda 部の実装が完了です。

おわりに

今回は、Lambda の残りの部分の実装を記事にいたしました。

あくまで「実践してみた」一例でしてこれが正解ではないと思いますが、ご参考になれば幸いです。

次回は IaC も組み替えたいと思います。

参考文献

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。
サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。


  1. 環境を整備したため記事の発表が遅くなりました… 
  2. body と header はAPIGatewayProxyEvent型でまとめてユースケース層への引数として渡す考え方もありますが、ユースケース層が API Gateway のサービスに依存するのでハンドラでばらして渡すようにしました。