AI make SaaS

Cloudflare Workers × Honoで作るAIチャットボット

はじめに

どうも、たにあんです。Cloudflare Workers上でHonoを利用してAnthropic APIを使ったチャットボット(名付けてClaudeちゃん)を作ってみたお話です。何回チャットbot作るねんって話なんですけど、これで最後だと思います。さすがに飽きました。

作る過程で気付いたポイントを記録しておこうと思います。JavaScriptやTypeScriptをなんとなくわかる前提で書きますが、Web Application Frameworkってなんやねんとか、開発しらんねんって人には参考になるかもです。知らんけど。

開発未経験、どの技術もほぼ経験なしの状態から作ったものなので、粗さはありますがご容赦願います。

前提

背景

今回の話はiPaaSに関連しています。12月のアドベントカレンダーでmakeについて書きましたので、参考まで以下のAdventarよりご覧ください。

Adventar – make Advent Calendar 2024

makeをはじめとするiPaaSは、すべてではないですが従量課金制で実行数がコストに直結します。特にSlackのEvent Subscriptionsでは実行数が増加しやすいです。

そこでCloudflare Workers × Honoを使用してSlackのEvent Subscriptionsを処理し、よりコスト効率の良いチャットボットを実装してみることにしました。iPaaSいらんやんという話にもなり得るんですが、それはまた別のお話。好奇心には勝てません。

Honoってなんやねん?

軽量なWebアプリケーションフレームワークで、各種JavaScriptランタイムとTypeScriptに対応しています。

素人コメントですが、シンプルな設計でドキュメントも読みやすく学習コストは低い、と当初感じていましたが、ドキュメントもシンプル(?)ゆえに、初めての人が触るようなフレームワークでもないような気もしています。ただ、Cloudflare WorkersのFreeプラン(実行時間の制約アリ)での利用も踏まえると適しているように思えました。

以下、Hono関連のドキュメントです。ご参考まで。

Hono – Docs
Zenn – Hono[炎]っていうイケてる名前のフレームワークを作っている

環境

利用したサービス

  • Slack
  • Cloudflare Workers (参考)
  • Cloudflare D1 Database (参考)
  • Anthropic API (参考)

開発環境

  • Cursor (参考)
    • あってもなくてもいいですが、僕の場合はなかったら多分作れてない。
  • Node.js v18↑
  • Hono (参考)
  • Wrangler (参考)
    • Cloudflare Workersの開発、デプロイツール
    • npx コマンドで実行するためインストール不要(のはず)
  • TypeScript

作ったもの

Githubに作ったファイルは全部公開しているのでどうぞ。READMEにも色々記載しているのでよろしければご覧ください。コードが汚いとか依存関係が汚いとかは初学者ゆえお許しを。

実際に動かしたときの画面、Githubにアップされているファイルの依存関係を示したものも参考までに記載しておきます。

ポイント

Githubで公開しているので、作ったものはそちら見ていただくとして、作っている最中に気をつけた点を掻い摘んで記載しておきます。

Anthropic APIのPDF Supportへの対応

GIFでお見せした通り、Anthropic APIは11月頃にベータリリースされたPDF Support機能があります(気づいたら正式リリースされているっぽい)。PDFファイルをアシスタントに投げることで、PDFの内容を読み取ってくれるヨという話です。

PDFが添付されていたらファイルを取得して読み取るような仕組みにしています。Base64に変換する必要があります。詳細はAnthropic APIのドキュメントをどうぞ。

参考:Anthropic – PDF Support

Slack Webhook Responseの処理

Events APIの処理をするにあたって、Webhook再送の仕様が厄介です。Anthropic APIのレスポンスを受け取るまで、どうしても時間かかるので処理を工夫する必要があります。

そこでHonoのexecutionCtx というAPIを使います。WebhookにResponseを返しつつ、バックグラウンドでAnthropic APIにリクエストを送信することができます。

ちなみに、ContextはRequestやResponseで使えるHonoのオブジェクトで、Requestからデータを取得したり、Responseで返すHTTPステータスを設定できたりという感じです。

以下に簡略化したコードを載せてます。

実際のコード(slack.ts)
interface SlackEventBody {
	...
}

export const createSlackEventHandler = (env: Env) => {
	...
	
  return async (c: Context<{ Bindings: Env }>) => {
    const requestId = getRequestId(c);
    console.log(`Processing Slack event with request ID: ${requestId}`);

    try {
      ...

      // 即座に200を返す
      c.header("X-Slack-No-Retry", "1");
      const response = c.text("OK", 200);

      // 非同期で後続の処理を実行
      if (body.type === "event_callback" && body.event) {
        const event = body.event;
        // メインスレッドをブロックしないように、waitUntilを使用して非同期タスクを実行
        c.executionCtx.waitUntil(
					...
        );
      }
      logger.debug("Send a response to Slack");
      return response;
    } catch (error) {
      logger.error("Error handling Slack event", error);
      return c.text("Internal Server Error", 500);
    }
  };
};

参考:Slack – Events API, Hono – executionCtx

ログを加工する

2025/1/16時点でまだベータ版ですが、Cloudflare Workersにはログ収集の機能があります。Cloudflare Workers内の任意の処理をログに収集することができます。実際に収集しているログはこんな感じ。

複数の処理が流れてくると視認性が著しく悪いので、ログの種別とどの実行に紐づいたもの(実行ごとにIDを払い出す)か各ログに付与することで、分かりやすくするようにしています。実務経験はないのでよくわかりませんが、ログの整形として一般的なのではないかと思っています。

実際のコード

logger.ts ・・・ログの種別を分かりやすくするように整形

import { Context } from "hono";
import { Env } from "../types";
import { getRequestId } from "./context";

class Logger {
  private requestId: string = "default";

  updateContext(c: Context<{ Bindings: Env }>) {
    this.requestId = getRequestId(c);
  }

  info(message: string, data?: any) {
    console.log(
      `[INFO][${this.requestId}] ${message}`,
      data ? JSON.stringify(data) : ""
    );
  }

  error(message: string, error?: any) {
    console.error(`[ERROR][${this.requestId}] ${message}`, error);
  }

  debug(message: string, data?: any) {
    console.debug(
      `[DEBUG][${this.requestId}] ${message}`,
      data ? JSON.stringify(data) : ""
    );
  }
}

export const logger = new Logger();

export function updateLoggerContext(c: Context<{ Bindings: Env }>) {
  logger.updateContext(c);
}

context.ts ・・・ Request毎に同じID(requestId)が払い出されるような処理

import { Context } from "hono";
import { Env, RequestContext } from "../types";

declare module "hono" {
  interface ContextVariableMap {
    requestContext: RequestContext;
  }
}

export function generateRequestId(): string {
  return crypto.randomUUID();
}

export function initializeRequestContext(c: Context<{ Bindings: Env }>) {
  if (!c.get("requestContext")) {
    c.set("requestContext", {
      requestId: generateRequestId(),
    });
  }
}

export function getRequestContext(
  c: Context<{ Bindings: Env }>
): RequestContext {
  return c.get("requestContext");
}

export function getRequestId(c: Context<{ Bindings: Env }>): string {
  return getRequestContext(c).requestId;
}

コラム:iPaaSとカスタムコードどっちがええねん?

本筋とは外れるのですが、背景に関わる話なので触れておきます。iPaaS(makeなど)と今回のようなカスタムコードどっちがええねん?って話。

情シスの端くれとして、個人的にはiPaaSの方がよいと考えています。今回のようにSlackからWebhookを発火するようなケースだと、実行数を気にしないといけないという話はありつつ、それ以外のメリットが大きいというのが正直なところです。
実装スピードやその後のメンテナンス性を考えると、当然ながらiPaaSには勝てません。makeに限った話で言えば、単一のモジュールのみを実行したり、特定のプロセスのみを実行したりとメンテナンスしやすいことも優れた点として挙げられます。

ただ、考える人の立場や置かれた環境によって異なると思いますし、どちらか一方が必ず良いという話でもないので、どのような手段で実現するのがよいか、各自ご検討いただければと思います。

iPaaSとカスタムコードで共通している内容も多いのですが、個人の考えをまとめておきます。

観点iPaaSカスタムコード
メンテナンス性:メリット・ノーコードでの修正が可能
・プラットフォーム側での保守
・完全な制御と柔軟な修正
・独自の保守計画の立案
・詳細なバージョン管理
メンテナンス性:デメリット・カスタマイズの制限
・ベンダーロックイン
・プラットフォームの仕様変更への依存
・継続的な保守要員の確保
・ドキュメント管理の負担
拡張性:メリット・新規コネクタの迅速な追加
・新機能の定期的な追加
・無制限の拡張可能性
・独自機能の自由な実装
・他システムとの緊密な統合
拡張性:デメリット・プラットフォームの制限内での拡張
・カスタム機能の実装制限
・開発リソースの確保
・拡張時の品質管理負担
・スケーリング対応の複雑さ
コスト効率:メリット・予測可能な運用コスト
・開発人材コストの削減
・長期的なコスト制御
・ライセンス費用の削減
コスト効率:デメリット・サブスクリプションコストの継続
・利用量増加時のコスト増
・高い初期開発コスト
・継続的な保守コスト
・人材採用および育成コスト
開発・導入速度:メリット・短期間での導入可能
・最小限の学習
・要件に適合しやすい
・段階的な開発が可能
・既存システムとの統合容易性
開発・導入速度:デメリット・複雑な要件対応の制限・テスト工程の負担
・リソース確保の時間

既存コードの改善ポイント

一通り作り終えて、この記事を書きながらHonoのドキュメントを読み直して、改善できそうなポイントをまとめておきます。作成中にドキュメントをほぼ読んでないのがバレますが、自戒も込めて残しておきます。AIの弊害というか進め方の問題もある気もするけど、これはこれで学習として良い気もしている。

Request IDってもしかしてHonoのミドルウェアでいけるんじゃ?

今回のような実装は必要なかったかもしれません。ただ、モジュール間で使用するにはどうしたらいいんだっけって感じで、結局Contextを拡張しておかないと使えないような気もしているのでファイルの依存関係は変わらない気もしています。

この記事を書きながら修正しました。シンプルになりました。Web Application FrameworkにはRequest IDを生成する機能がミドルウェアとして基本的に提供されているみたいです。へぇ〜。

コード

修正前

修正後

import { Context } from "hono";
import { Env, RequestContext } from "../types";

declare module "hono" {
  interface ContextVariableMap {
    requestContext: RequestContext;
  }
}

export function generateRequestId(): string {
  return crypto.randomUUID();
}

export function initializeRequestContext(c: Context<{ Bindings: Env }>) {
  if (!c.get("requestContext")) {
    c.set("requestContext", {
      requestId: generateRequestId(),
    });
  }
}

export function getRequestContext(
  c: Context<{ Bindings: Env }>
): RequestContext {
  return c.get("requestContext");
}

export function getRequestId(c: Context<{ Bindings: Env }>): string {
  return getRequestContext(c).requestId;
}
import { Context } from "hono";
import { RequestIdVariables } from "hono/request-id";
import { Env } from "../types";

declare module "hono" {
  interface ContextVariableMap extends RequestIdVariables {}
}

export function getRequestId(c: Context<{ Bindings: Env }>): string {
  return c.var.requestId;
}

参考:Hono – Request ID Middleware

Loggerも作る必要なかったのか・・・?

リンクに記載されている通りですが、Logging Detailsを見るとRequestとResponseしか見てくれないような感じなので、logger.tsのようなコードは必要な気もします。知識不足なり。修正するの大変そうなので避けてます。

参考:Hono – Logger Middleware

validatorを使ったほうがいいかも

既存のコードはtypes.tsなどで型を定義していますが、Honoの機能でValidationを挟むことで適切な型がInputされているか確認もしながら、外部APIへのリクエストを送信できると思います。本来であればやった方がいいんでしょうけど、今回はそこまで手が回らず。開発に慣れていないので、そもそもメリットをあまり感じ取れていないところもあります。TypeScriptを使っている以上、使わないわけないだろって感じかもしれません。知らんけど。

Zodなどの3rd-partyのライブラリを使うという方法もあるようで、こちらの方がより一般的な印象があります。Hono公式でもZodが推奨されている様子で、Zod Middlewareも用意されています。

対象のコード
import { MessageService } from "../services/messageService";
...

export const createSlackEventHandler = (env: Env) => {
  ...

  return async (c: Context<{ Bindings: Env }>) => {
    const requestId = getRequestId(c);
    console.log(`Processing Slack event with request ID: ${requestId}`);

    try {
      ...
      
      if (!signature || !timestamp) {
        logger.error("Missing Slack signature or timestamp");
        return c.text("Unauthorized", 401);
      }

      if (!slackClient.verifySlackRequest(signature, timestamp, rawBody)) {
        logger.error("Invalid Slack signature");
        return c.text("Unauthorized", 401);
      }

      const body = (await c.req.json()) as SlackEventBody;
      logger.info("Received Slack event", {
        type: body.type,
        event: body.event?.type,
      });

      // Handle URL verification
      if (body.type === "url_verification" && body.challenge) {
        logger.info("Handling URL verification challenge");
        return c.text(body.challenge, 200);
      }

      // 即座に200を返す
      c.header("X-Slack-No-Retry", "1");
      const response = c.text("OK", 200);

      // 非同期で後続の処理を実行
      if (body.type === "event_callback" && body.event) {
        const event = body.event;
        ...
      }
      logger.debug("Send a response to Slack");
      return response;
    } catch (error) {
      logger.error("Error handling Slack event", error);
      return c.text("Internal Server Error", 500);
    }
  };
};

Testing Helper

正直、今回のケースではどうやって使えばいいかわかりませんでした。

testClient を利用することでAPIテストを作れたりするようですが、Webhookの場合だとデータ構造が面倒くさすぎて厳しいかなと思ったり思わなかったり。やった方がいいんでしょうけど。

参考:Hono – Testing Helper, Zenn – 見よ、これがHonoのRPCだ

さいごに

ほぼAIに作ってもらったので、自分で作った感じはないですが、ひとまずHonoでどんなことができるかの基本的なことはコードを眺めながら掴めたと思います。「意外と簡単に作れるんだな」という確信というか自信というかを得られたのも大きいと思います。

また、作り終わってからHonoのドキュメントを改めて見るとより理解が深まり、次のステップに活かせると思います。みなさんもぜひお試しあれ。引き続き、Honoを使って何かできないかは考えていきたいと思います。

それではさらば。

たにあん

2024年12月運用支援サービス(仮)のメンバーとして入社。
とりあえずやってみるをモットーにおしごとをしています。
好きなものは甘いもの。
嫌いなものは甘いものを食べていない時間。