honoで駆動する獣医師とのマッチングサービス開発
この記事は?
本記事はhono16日目のアドベントカレンダーへの寄稿記事となります! 🔥
著者は?
著者はサーバーサイドTypeScriptを専門領域としており、エンジニアとして社内外でのTypeScript開発から技術指導まで幅広く活動してきました。最近では「うぃずちゃみー」という獣医師とマッチングできてペットの悩みを相談できるサービスを展開しており、バックエンドにはhonoを採用しています。
このサービスでは、本記事で紹介する顧問による記事公開承認など一部処理が煩雑になるパートもあり、バックエンドはできるだけ堅牢に開発をしていくとともに、診療情報の処理などセキュアなパートもあるのでしっかりと(漏れなく)データのバリデーションを行っていきたい思いがあります。
なぜhonoをバックエンドに選定したのか?
著者はexpress, nest, Next.jsでのroutes機能を用いての実装、hasuraでのAPI実装から、とくにフレームワークは使わずにピュアなTypeScriptを書いてCLIを実装していくプロジェクトまで色々なサーバーサイド技術の経験をしてきたつもりではありますが、こういった色々な経験を経た結論としてhonoの使用を最有力に考えるようになりました。具体的にhonoを使うと嬉しいポイントを整理しましょう。
著者が考えるhonoを使うと嬉しいポイント
- node.jsに依存せず多様な環境で動作できる
- 型安全でありRPCによって型をClient/Serverで共有できる
- OpenAPI Docを自動生成することもできる
- Zodを用いたバリデーションと相性が良い
- 軽量であり他FWより高速に動作するケースがある
- 学習難度が低く他FWの出身者でも入りやすい
- 関数型での記述が可能である(これはexpressなどでも可能
まず、node.jsに依存しておらず標準APIをベースに設計されているので、edgeやnode.jsで動かない環境、例えばbrowserでも (chromeで動作確認済み。)動作ができます。これによって環境依存でアプリが動かないというリスクを減らすことができます。
次に、後ほど示す実装サンプルを見ていただくと、型安全でありRPCによって型を共有できるメリットがあるので堅牢であり、開発者体験がよく、スピーディな開発が期待できます。
また、honoでは標準機能として、Routeの実装からOpenAPIを自動生成することもできるのでサーバーサイドにhonoを使ってクライアントサイドはReactのCSRなどで分離する場合などに必要となるAPI Docの作成に時間をかける必要がないので、チーム連携も容易でしょう。
さらにZodと相性がよくZodと連携することでより堅牢な開発が期待できます。
また、honoは軽量であり他のFWよりも高速に動作するケースがあります。
公式のbenchmarksの項目では、Routers, CloudFlare Workers, Deno, Bunそれぞれでhonoが他FWと比較しても高いベンチマークを示すことが示されています。
続いて、honoは学習難度が低く、最も簡単なAPI実装は以下で完了します。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hono!'))
export default app
ただ私は色々なFW/ノンFWでの開発を経験してきた上で、そのように思う主観が入っているかもしれないので、Getting Startedから実際に試してみると簡単だと思うかもしれません。
最後に、他のFWでは関数型で開発がしにくいFWもありますが堅牢な開発を行うために、堅牢さを求めて関数型を採用しようとすると、honoは関数/Classの制約がないため、関数型で実装が可能になります。実際どう実装をするか?は紙面の関係上本記事では詳細は触れませんが、興味のある方は文献を参照ください (サンプルはF#での実装ですがhonoにも転用可能な内容となっています)。
1. 顧問による記事レビュー機能設計
さて、実際にhonoを使ったアプリの設計を考えて見ましょう。
例えば、私のような獣医としての資格のないメンバーが動物に関する記事を公開しようと思うと、獣医師の顧問にレビューを受けて承認を得てから公開プロセスに進む必要があります。
このプロセスをMermaidで図示するとざっくりと以下の通りですが、honoのアドカレということで、続いてこれを実装する際にhonoで嬉しいポイントを実際に見ていきましょう。
2. 記事レビュー機能へのhono実装サンプル
(※ 実装サンプルはhonoの実装イメージを掴んでいただくために簡略化したもの。本番環境ではエラーハンドリング等や分割などより多くの実装が必要となることに留意してください。)
import { Hono } from 'hono'
import { PrismaClient } from '@prisma/client'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
type ArticleParams = {
id: string
}
const prisma = new PrismaClient()
const app = new Hono()
// 🔥バリデーションスキーマ
const articleSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
authorId: z.string(),
tags: z.array(z.string()).optional(),
})
const paramSchema = z.object({
id: z.string()
})
const reviewSchema = z.object({
reviewerId: z.string(),
approved: z.boolean(),
reviewNote: z.string().optional()
})
// ❤️🔥記事投稿
app.post('/articles', zValidator('json', articleSchema), async (c) => {
const data = c.req.valid('json')
try {
const article = await prisma.article.create({
data: {
...data,
status: 'PENDING'
}
})
return c.json({ success: true, data: article }, 201)
} catch (error) {
return c.json({ success: false, error: 'Failed to create article' }, 400)
}
})
// ❤️🔥記事のレビュー
app.put('/articles/:id/review',
zValidator('param', paramSchema),
zValidator('json', reviewSchema),
async (c) => {
const { id } = c.req.valid('param')
const { reviewerId, approved, reviewNote } = c.req.valid('json')
try {
// 獣医師チェック
const reviewer = await prisma.user.findUnique({
where: { id: reviewerId }
})
if (!reviewer || reviewer.role !== 'VETERINARIAN') {
return c.json({
success: false,
error: 'Unauthorized: Reviewer must be a veterinarian'
}, 403)
}
const article = await prisma.article.update({
where: { id },
data: {
status: approved ? 'APPROVED' : 'REJECTED',
reviewerId,
reviewNote
}
})
return c.json({ success: true, data: article })
} catch (error) {
return c.json({ success: false, error: 'Review failed' }, 400)
}
})
// ❤️🔥記事取得
app.get('/articles/:id', zValidator('param', paramSchema), async (c) => {
const { id } = c.req.valid('param')
try {
const article = await prisma.article.findUnique({
where: { id }
})
if (!article) {
return c.json({ success: false, error: 'Article not found' }, 404)
}
return c.json({ success: true, data: article })
} catch (error) {
return c.json({ success: false, error: 'Failed to fetch article' }, 400)
}
})
export default app
まず、バリデーションスキーマを定義することによって、APIへのリクエストが想定しないものであった場合にBad Requestエラーを返せるようになって堅牢になります。
また、以下のようにinferを用いることでフロントエンドでAPI型は共有可能であり、@hono/swagger-ts を用いることによって自動でOpen API Docを作成することができます。
type Article = z.infer<typeof articleSchema>
続いて、routeの部分は以下のように書くことができ、
pathが紐づいたCRUDを直感的に作成することが可能になっているともに、
zValidatorを引数として渡すことでバリデーションの実行と型安全にrequestから値を取得することが可能になっており、ここはc.req.valid('param')
の部分が該当します。
app.get('/articles/:id', zValidator('param', paramSchema),
async (c) => {
const { id } = c.req.valid('param')
...残りの処理
}
終わりに) honoで駆動する獣医師マッチングの開発
このサービスの開発は始まったばかりで、まだまだ開発を進めていかないといけないことは多いです。
獣医療業界の課題は山積みで、
実際にも、6割の獣医師が、獣医療業界は人材不足と回答しました。
オンラインで獣医師とマッチできることで、獣医師の力を少しでも人材不足に苦しむ現場や飼い主の皆様に届けられないか?と考え、開発者経験もよく高速に開発できそうなhonoを選定しました。
それとともに、獣医療業界から十分な恩恵を受けられない飼い主の悩み感や、ペットを飼う上での多種多様な課題解決は幅広く、一般的な健康問題からペットロスなどによる飼い主の心理的負担など多岐に渡ります。課題を解決するために、より気軽にオンラインでの獣医師とのマッチができるような環境を目指して今後ともwith honoでそういった環境の実装を推進していこうと思います。
Chummy
Discussion