こんにちは〜!皆様いかがお過ごしでしょうか? no plan inc. CTOの@serinuntius です。
これはno plan inc.の Advent Calendar 2023の12日目の記事です。
ずっとHonoを使って何かを作ってみたいと思っていまして、作っておくと便利そうなものを作りました。
テーマはLine Messaging APIです。
LineのMessaging APIを無料で利用しようとすると、友達追加してくれたuserIdのリストができません。
!もちろん有料のAPIでは友達追加したユーザーのリスト得ることはできるようです。
この機能は認証済アカウントまたはプレミアムアカウントのみでご利用いただけます。アカウント種別について詳しくは、『LINEヤフー for Business』の「LINE公式アカウント アカウント種別 (opens new window)」を参照してください。
https://developers.line.biz/ja/reference/messaging-api/#get-follower-ids
ドキュメントによると4つの方法があるようです。
無料で普通の用途で考えると2のWebhookからユーザーIDを取得するしかありません。
ただメッセージを送りたいだけなのに、いちいちWebhookのエンドポイントを作成するのはDRYじゃありません。
Lineのダメなところをいい感じにラップしてくれるAPIを作成すれば、今後Line botを作成するときに捗るんじゃないかと考えました。
モチベーションの共有ができたところで、環境を作成していきたいと思います。
pnpm create hono my-appcreate-hono version0.3.2✔ Using target directory … my-app? Which templatedo you want to use? › - Use arrow-keys. Return to submit. aws-lambda bun cloudflare-pages❯ cloudflare-workers# 今回はCloudflare Workersを使いたいのでこちらを選択 deno fastly lagon lambda-edge netlify ↓ nextjscd my-apppnpm i
まずはローカルサーバーを立ち上げてみる
pnpm dev> @ dev /private/tmp/my-app> wrangler dev src/index.ts ⛅️ wrangler3.19.0-------------------⎔ Startinglocal server...[wrangler:inf] Ready on http://localhost:8787
こんな感じの出力が出るので、http://localhost:8787
にアクセスしてみましょう。
curl http://localhost:8787Hello Hono!
と出力されましたか?
試しにsrc/index.ts
を適当にエディットして、レスポンスの文字列を変えてみましょう。
当たり前のようにホットリロードがされましたね?最高だぜ!
curl http://localhost:8787Hello Hono!!!!
wranglerというCloudflareの管理用cliコマンドがありまして、D1の操作やWorkersのデプロイの際に使います。
ログインして、連携しておきましょう。
pnpm wrangler login
d1というserverlessの SQL Databaseがあります。こいつをDBにしてCRUDを作ってみましょう。
d1はまだパブリックベータな為、対応しているORMが少ないです。
軽く調べた結果drizzle
というORMが有力そうだったので、そちらを使ってみます。
pnpm wrangler d1 create<DB-NAME>✅ Successfully created DB'test-db'in region APACCreated your database using D1's new storage backend. The new storage backend is not yet recommendedfor productionworkloads, but backs up your data via point-in-time restore.[[d1_databases]]binding="DB"# i.e. available in your Worker on env.DBdatabase_name="test-db"database_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
こんな感じで出力されるので、d1_database
以下のところをwrangler.toml
にコピーします。
name="my-app"compatibility_date="2023-01-01"main="src/index.ts"# [vars]# MY_VARIABLE = "production_value"# [[kv_namespaces]]# binding = "MY_KV_NAMESPACE"# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"# [[r2_buckets]]# binding = "MY_BUCKET"# bucket_name = "my-bucket"[[d1_databases]]binding="DB"# i.e. available in your Worker on env.DBdatabase_name="test-db"database_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
pnpmadd drizzle-orm drizzle-zodpnpmadd-D drizzle-kit
適当に今回のモデリングをしてみました。一旦こんな感じで行ってみましょう。
import{ sqliteTable, integer, text}from"drizzle-orm/sqlite-core";import{ createInsertSchema, createSelectSchema}from'drizzle-zod';exportconst users=sqliteTable('users',{ id:integer("id",{ mode:"number"}).primaryKey({ autoIncrement:true}), name:text("name"), lineUserId:text("lineUserId").notNull().unique("lineUserId"), createdAt:integer("createdAt",{ mode:"timestamp"}).notNull(), updatedAt:integer("updatedAt",{ mode:"timestamp"}).notNull()})exportconst insertUsersSchema=createInsertSchema(users);exportconst selectUsersSchema=createSelectSchema(users);exportconst bots=sqliteTable('bots',{ id:integer("id",{ mode:"number"}).primaryKey({ autoIncrement:true}), name:text("name").notNull().unique("name"), lineChannelAccessToken:text("lineChannelAccessToken").notNull(), createdAt:integer("createdAt",{ mode:"timestamp"}).notNull(), updatedAt:integer("updatedAt",{ mode:"timestamp"}).notNull()})exportconst insertBotsSchema=createInsertSchema(bots);exportconst selectBotsSchema=createSelectSchema(bots);exportconst messages=sqliteTable('messages',{ id:integer("id",{ mode:"number"}).primaryKey({ autoIncrement:true}), userId:integer("userId",{ mode:"number"}).notNull(), botId:integer("botId",{ mode:"number"}).notNull(), text:text("text").notNull(), createdAt:integer("createdAt",{ mode:"timestamp"}).notNull(), updatedAt:integer("updatedAt",{ mode:"timestamp"}).notNull()})exportconst insertMessagesSchema=createInsertSchema(messages);exportconst selectMessagesSchema=createSelectSchema(messages);
他の記事ではこんな感じでtimestampの設定をしていたのですが、私の環境では動きませんでした。
.default( sql`(strftime('%s','now'))`),
調査に1mmも時間使ってないですが、なしにしたらとりあえず動いたので、それで進めました。
drizzleをいい感じに動かすためのコンフィグです。適宜自由に設定していただいても結構ですが、他の場所で参照してたりするので、とりあえずはこれと同じにしておくことをお勧めします。
importtype{ Config}from"drizzle-kit";exportdefault{ schema:"./src/schema.ts", out:"./drizzle/migrations", driver:"d1", dbCredentials:{ wranglerConfigPath:"wrangler.toml", dbName:"<DATABASE-NAME>", #FIXME},} satisfies Config;
scriptsに以下のコマンドを登録しておきましょう。
..."generate":"pnpm drizzle-kit generate:sqlite"...
pnpm generate> @ generate /private/tmp/my-app>pnpm drizzle-kit generate:sqlitedrizzle-kit: v0.20.6drizzle-orm: v0.29.1No config path provided, using default'drizzle.config.ts'Reading configfile'/private/tmp/my-app/drizzle.config.ts'3 tablesbots5 columns1 indexes0 fksmessages6 columns0 indexes0 fksusers5 columns1 indexes0 fks[✓] Your SQL migrationfile ➜ drizzle/migrations/0000_needy_phil_sheldon.sql 🚀
こんな感じでmigrationファイルが生成されたら成功です!
[[d1_databases]]binding="DB"# i.e. available in your Worker on env.DBdatabase_name="test-db"database_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"# 追記するmigrations_dir="drizzle/migrations"
package.jsonのscriptsに以下のコマンドを登録しておきます。
..."migrate:local":"wrangler d1 migrations apply <DB-NAME> --local","migrate":"wrangler d1 migrations apply <DB-NAME>"...
ではローカルに対してmigrateしてみましょう。
pnpm migrate:local> @ migrate:local /private/tmp/my-app> wrangler d1 migrations apply test-db--localMigrations to be applied:┌─────────────────────────────┐│ name │├─────────────────────────────┤│ 0000_needy_phil_sheldon.sql │└─────────────────────────────┘✔ About to apply1 migration(s)Your database may not be available to serve requests during the migration, continue? …yes🌀 Mapping SQL input into an array of statements🌀 Executing onlocal database test-db(f676b3fc-ed0a-479c-8c29-7381ebc91c23) from .wrangler/state/v3/d1:┌─────────────────────────────┬────────┐│ name │ status │├─────────────────────────────┼────────┤│ 0000_needy_phil_sheldon.sql │ ✅ │└─────────────────────────────┴────────┘
はい、成功しました!
ついでに本番もマイグレーションしておきましょう。
pnpm migrate
zodがないと生きれない身体になってしまいました。
pnpmadd zod @hono/zod-validator
例えばbotはこんな感じで実装しました。
exporttypeBindings={DB: D1Database;};
CRUDを雑に実装しようとするとc.json()
のところでエラーが出ました。Date型をシリアライズできないっていうエラーですね。
雑にこんな感じで対処しましたが、もっといい方法ありそう・・・。
exportfunctionconvertDatesToStr(data:any):any{// オブジェクトの`Date`フィールドをISO文字列に変換for(const keyin data){if(data[key]instanceofDate){ data[key]= data[key].toISOString();}}return data;}
import{ drizzle}from"drizzle-orm/d1";import{ Hono}from"hono";import{ Bindings}from"./bindings";import{ bots, insertBotsSchema}from"./schema";import{ convertDatesToStr}from"./utils/db";import{ z}from'zod'import{ zValidator}from'@hono/zod-validator'import{ eq}from"drizzle-orm";// D1使えるようにBindingsを渡すconst botRoute=newHono<{ Bindings: Bindings}>();botRoute.get('/',async(c)=>{const db=drizzle(c.env.DB);const result=await db.select().from(bots).all();return c.json(convertDatesToStr(result));})// zValidatorはいいぞ(後で解説します)botRoute.get('/:id',zValidator('json', z.object({ id: z.number()})),async(c)=>{const{ id}= c.req.valid('json');const db=drizzle(c.env.DB);const result=await db.select().from(bots).where(eq(bots.id, id)).limit(1).get();return c.json(convertDatesToStr(result));})// DBのschemaからinsertに必要な型を作成してくれる (drizzle-zodの魔法)// ただtimestampはサーバー側で生成したいので、適当にomitしてるbotRoute.post('/',zValidator('json', insertBotsSchema.omit({ createdAt:true, updatedAt:true})),async(c)=>{const{ name, lineChannelAccessToken}= c.req.valid('json');const db=drizzle(c.env.DB);const result=await db.insert(bots).values({ name, lineChannelAccessToken, createdAt:newDate(), updatedAt:newDate()}).execute();return c.json(convertDatesToStr(result));});botRoute.delete('/:id',zValidator('json', z.object({ id: z.number()})),async(c)=>{const{ id}= c.req.valid('json');const db=drizzle(c.env.DB);const result=await db.delete(bots).where(eq(bots.id, id)).execute();return c.json(convertDatesToStr(result));});export{ botRoute}
ルートはこんな感じ
import{ Hono}from'hono'import{ logger}from'hono/logger'import{ showRoutes}from'hono/dev'import{ webhooksRoute}from'./webhooks'import{ usersRoute}from'./user';import{ botRoute}from'./bot';import{ Bindings}from'./bindings';const app=newHono<{ Bindings: Bindings}>()// ロガーを追加app.use('*',logger());app.route('/api/webhooks', webhooksRoute);app.route('/api/users', usersRoute)app.route('/api/bots', botRoute)exportdefault app// いい感じにRailsみたいなルートを表示してくれるshowRoutes(app)
webhooksのところとusersのところもほぼ一緒で特に何もないのでそんな感じで・・・。(本当は時間あればちゃんとLINEとの連携までやりたかった・・・)
pnpm run deploy# pnpmにはdeployというサブコマンドがあるので、混同されないようにrunをつける# https://pnpm.io/ja/cli/deploy
動作確認してみる
curl https://<app name>.<user name>.workers.dev/api/bots[]
何も入ってないのでそれはそうw
適当にデータを登録してみよう。
curl-X POST-H'Content-Type: application/json'-d'{"name": "test20", "lineChannelAccessToken": "hoge"}' https://<app name>.<user name>.workers.dev/api/bots{"success":true,"meta":{"served_by":"v3-prod","duration":0.2303,"changes":1,"last_row_id":3,"changed_db":true,"size_after":45056,"rows_read":3,"rows_written":3},"results":[]}%
もう一度確認してみる
curl https://<app name>.<user name>.workers.dev/api/bots| jq. % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed1004041004040035680 --:--:-- --:--:-- --:--:--3672[{"id":1,"name":"test20","lineChannelAccessToken":"hoge","createdAt":"2023-12-11T15:29:39.000Z","updatedAt":"2023-12-11T15:29:39.000Z"}]
うまくいきました!
Honoは本当にすごい。Cloudflareもすごい。D1もすごい。zodもすごい。
語彙力が低下してしまうほど、感動しました。
デプロイがめちゃくちゃ速いのいいですね。
某Functionsとかどんだけ時間かかるか・・・。
エンジニアの採用も積極的に行なっていますので、興味がある方は是非ご連絡ください!
https://hono.dev/getting-started/cloudflare-workers
https://hono.dev/guides/validation#with-zod
https://developers.cloudflare.com/d1/
https://qiita.com/kmkkiii/items/2b22fa53a90bf98158c0
https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
https://github.com/drizzle-team/drizzle-orm/tree/main/drizzle-zod
https://developers.line.biz/ja/docs/messaging-api/receiving-messages/#webhook-event-types
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
人類ネコ化計画を企み @noplan_incを起業しCTOに🚀 ブロックチェーン⛓の世界から人類をハックしています🧠 本業はお昼寝😴INTP🧑🔬/ 最近のお気に入りはRust🦀/Mastra/Cloudflare/ピックルボール🏓