この記事の概要
普段はフロントエンジニア🐣の筆者が、訳あってAzure Functionsを用いバックエンド実装をすることに。
Honoというフレームワークに出会い、色々調べながら
1. Azure Functions(Static Web Apps付帯)上にHonoアプリを載せる
2. Swagger UIを作る
3. RPC機能を使ってフロントからのAPI通信を実装する
……ところまでを実装し、「Honoすごい!!!!!」と賞賛するに至るまでを紹介する。
経緯
業務にて、Azure Static Web Apps付帯のAzure Functionsを用いバックエンド実装をすることに。
フロントアプリはTypeScript + Reactで構築予定。
バックエンドは、他案件では大抵Python(Fast API)で構築していたが……
どうせならTypeScriptで作って、クライアント↔︎サーバー間で型の共有とかできるようにしたい。
(それに筆者Pythonの開発経験あんまりないし(小声))
ということで色々調べていたら、Honoというイケてるフレームワークを発見した……。
Honoって?
読み方は「ほの」ではなく「ほのお」。炎である🔥
HonoはCloudflare Workersに特化した「Webアプリを作るためのフレームワーク」として開発がスタート。
現在ではCloudflareのみならず、Fastly、Deno、Bun、AWS、Vercel、Node.jsなど、マルチに対応している。
(詳細は下記リンク先参照)
そして、Azure Functionsにも対応している……!
Honoが持つさまざまな機能についての紹介は割愛するが、とにかくなんでもできる。
今回はZodを活用したSwagger UI生成機能とRPC機能を盛り込みつつ、Azure Functions上にHonoアプリを載せるところまでを実装する。
構成概要
アプリ構成
クラウドリソース | フレームワーク | |
---|---|---|
フロントエンド | Azure Static Web Apps | React + Vite + TypeScript |
バックエンド | Azure Static Web Apps 付帯の Azure Functions(v4) | TypeScript + Hono |
ディレクトリ構成概要
作成したディレクトリ構成はこちら(下記)
api
ディレクトリ配下にAzure Functionsのソースを格納している。フロントアプリのソース内にAzure Functionsが同居しているイメージ。
この構成自体はAzure Static Web Apps公式Docsに倣っている。
(参考:Azure Functions を使用して Azure Static Web Apps に API を追加する | Microsoft Learn)
api/app
ディレクトリでHonoアプリを構築し、api/functions/httpTrigger.ts
でこのHonoアプリを呼び出している。
api/app
配下はなるべく他案件のPythonアプリと同じディレクトリ構成にしている。
mainアプリでrouter各種をまとめ、routerでは各パス各HTTPメソッドごとに関数を設定、applicationsを呼んでロジック実行。DB接続系はrepositoriesにまとめ、必要に応じてapplicationsの関数から呼び出す。
.
|-- dist/
|-- node_modules/ ………… フロントアプリのnode_modules
|-- public/
| └-- staticwebapp.config.json
|-- src/ ………… フロントアプリのソース
|-- index.html
|-- package.json
|-- tsconfig.json
└── api/ ………… Azure Static Web Apps付帯のバックエンドAPI(Azure Functions)
├── .env
├── dist
├── host.json
├── local.settings.json
├── node_modules
├── package.json
├── tsconfig.json
└── src/
├-- app/ ………… Honoで構築したアプリ
| ├-- routes/ ………… ルーティング設定
| | ├-- <domain名>/
| | | ├-- index.ts ………… ルーティング設定
| | | └-- honoClient.ts ………… フロントアプリ側でRPCモードで通信するためのクライアントオブジェクト
| | └-- <domain名>/
| | | ├-- index.ts
| | | └-- honoClient.ts
| ├-- schemas/ ………… リクエスト/レスポンス型スキーマ定義
| | ├-- <domain名>/
| | | └-- index.ts
| | └-- <domain名>/
| | └-- index.ts
| ├-- applications/ ………… 各ルートアクセス時のロジック
| | ├-- <domain名>/
| | | └-- index.ts
| | └-- <domain名>/
| | └-- index.ts
| ├-- repositories/ ………… DB接続処理
| | ├-- <domain名>/
| | | └-- index.ts
| | └-- <domain名>/
| | └-- index.ts
| └-- main.ts ………… /routes で定義したルーティング設定のグルーピング、Swagger UI生成
|
└-- functions/ ………… Azure Functions関数
└-- httpTrigger.ts ………… path `/` へのHTTP Triggerで起動する関数定義
実装ポイント① httpTrigger.tsにHonoアプリを載せる
Azure Functionsは何らかのイベントをトリガーに発火する。今回はHTTPリクエストをトリガーに関数を呼び出す。
下記はMS公式Docs記載のAzure Functions実装例。
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
export async function httpTrigger1(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
context.log(`Http function processed request for url "${request.url}"`);
const name = request.query.get('name') || (await request.text()) || 'world';
return { body: `Hello, ${name}!` };
}
app.http('httpTrigger1', {
methods: ['GET', 'POST'],
authLevel: 'anonymous',
handler: httpTrigger1,
});
11行目以降のapp.http
でルーティング設定をしている。
関数名httpTrigger1
というパスにGET
・POST
リクエストが来たら、関数httpTrigger1
を実行する…………という内容になっている。
Azure Functionsだけでルーティング設定を行うのであればこの構成で良い。
が、今回ルーティング設定はAzure functionsではなくHono側で実行したい。
どのパスにどのHTTPメソッドのリクエストが飛んだとて、Honoのアプリをトリガーするようにしたい!!!
ということで、以下のように実装した。
import { app } from "@azure/functions";
import { azureHonoHandler } from "@marplex/hono-azurefunc-adapter";
import honoApp from "../apps/main";
app.http("httpTrigger", {
methods: [
// Add all your supported HTTP methods here
"GET",
"POST",
"DELETE",
"PUT",
],
authLevel: "anonymous",
route: "{*proxy}",
handler: azureHonoHandler(honoApp.fetch),
});
注目して欲しいのはapp.http
の第二引数options
オブジェクト。
ここのroute
設定は、「関数がどの要求 URL に応答するかを制御するルート テンプレートを定義」する(参照:Azure Functions の HTTP トリガー > 構成)。
ここに{*proxy}
という値をセットした。
中かっこ{}
で囲まれた文字列はパスパラメータ名を示す。たとえば/pets/{petId}
とした場合、/pets/cat01
とかがマッチする。
さらに、/pets/{*petId}
のようにワイルドカード付きにすると、リクエストURLの残りのパスセグメントの文字列表現になる。/pets/cat01
でも/pets/cat/01
でも/pets/cat/mike/01
でもマッチする、というわけだ。
(参考:レガシ Azure Functions プロキシを使用する | Microsoft Learn)
ということは、{*proxy}
は、/book
にも/book/list
にも/book/info/hoge
にもマッチする。
これをapp.http
の第二引数options
のroute
設定で指定したことで、どんなパスへのHTTPリクエストであろうと本Functionsを呼び出すことができるようになった。
やったね!!!
そして、handler
設定では HonoのAzure Functions Adapterを使ってHonoアプリを載せている。
詳細は下記参照。
これだけでHonoアプリをAzure Functionsに載せることができた…………!
実装ポイント② Swagger UI生成
FastAPIといえば自動ドキュメント生成機能が大きなメリットの一つ(だと思っている)。
他案件ではFastAPIを活用し、Swaggerを見ながら仕様理解を深めている弊組織(わかりやすくていいよね)。
ということで、今回「Honoに乗り換えたからSwagger作れなかったんですよね🤪」は絶対に許されない。
でもその辺もHonoはしっかりサポートしていた…………。
今回はこちらのHonoミドルウェアzod-openapiを用いて実装。
実装例はこちら。
api/apps/router/book/index.ts
にて、zod-openapiを用いてHonoAppbookApp
を生成。書籍一覧を取得するAPIをbookApp
に追加し、エクスポートしている。
APIのリクエスト・レスポンス定義ではZodのスキーマ定義を用いるが、このスキーマ内で細かい値の情報(項目ごとの説明文やvalueの例など)を記載すると、Swagger UIにも反映される。
import { z } from "@hono/zod-openapi";
/** book情報 ベース - スキーマ定義 */
const bookInfoBaseSchema = z.object({
id: z.string().openapi({
example: "71b37846-e855-4736-97f3-f8d730b92208",
description: "Book ID",
}),
createdAt: z.string(),
updatedAt: z.string(),
label: z.string().openapi({
example: "テストBook1", // `label`の参考value
description: "本のタイトル", // `label`の説明文
}),
description: z.string().nullable().openapi({
example: "本の説明文です。",
description: "本の説明",
}),
});
/**
* ***********************
* GET /api/book/{id}
* ***********************
*/
/** GET /api/book/{id} リクエスト - スキーマ定義 */
export const getBookByIdRequestSchema = z.object({
id: z.string().openapi({
param: { // パスパラメータであることを示す
name: "id",
in: "path",
},
example: "book1",
}),
});
/** GET /api/book/{id} リクエスト - 型定義 */
export type GetBookByIdRequest = z.infer<typeof getBookByIdRequestSchema>;
/** GET /api/book/{id} レスポンス - スキーマ定義 */
export const getBookByIdResponseSchema = z.object({
result: bookInfoBaseSchema,
});
/** GET /api/book/{id} レスポンス - 型定義 */
export type GetBookByIdResponse = z.infer<typeof getBookByIdResponseSchema>;
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
getBookByIdRequestSchema,
getBookByIdResponseSchema,
} from "../../schemas/book";
import * as application from "../../applications/book";
const bookApp = new OpenAPIHono();
// 〜〜〜中略〜〜〜
/** GET /api/book/{id} */
export const getBookByIdRoute = bookApp.openapi(
createRoute({
method: "get",
path: "/api/book/{id}",
description: "Get book by ID", // SwaggerUIに表示するAPI説明文
tags: ["book"], // SwaggerUIのタグ
request: {
params: getBookByIdRequestSchema,
},
responses: {
200: {
content: {
"application/json": {
schema: getBookByIdResponseSchema,
},
},
description: "book info",
},
},
}),
async (c) => {
const id = c.req.param("id");
const res = await application.getBookById(id!);
return c.json(res, 200);
},
);
// 〜〜〜中略〜〜〜
export { bookApp };
そして、bookApp
のようなルーター各種を束ねているのがapi/apps/main.ts
。
ここでHonoAppapp
を生成し、ルーター各種を取り込んでいる。
さらに、app
に対しSwagger UIの公開設定を行っている。
-
/api/openapi
でAPI仕様のオブジェクトを公開 -
/api/docs
へのGETリクエストで、/api/openapi
で公開している情報をSwagger UIにし生成
……という流れである。
import { logger } from "hono/logger";
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { bookApp } from "./router/book";
const app = new OpenAPIHono()
// Swagger UI
.doc("/api/openapi", { // このパスにアクセスするとOpenAPIで生成されたAPI仕様JSONが表示される
info: {
title: "An API",
version: "v1",
},
openapi: "3.1.0",
})
.get(
"/api/docs", // このパスにアクセスすると、API仕様JSONをSwagger UIに変換したものが表示される
swaggerUI({
url: "/api/openapi",
}),
);
app.use("*", logger());
app.route("/", bookApp);
export default app;
さて、実際にapi/docs/
にアクセスして確認してみよう。
ローカルサーバーhttp://localhost:7071/api/docs
にアクセスすると……。
ちゃんとAPI仕様が公開されている!!!
やったねできたね!!!
実装ポイント③ RPC機能
RPC機能を使うと、サーバとクライアントでAPI仕様を共有でき、型安全かつ高速にクライアントとサーバーの通信を実装できる。
類似ツールにtRPCやts-restなどがあるが、HonoにはデフォルトでRPC機能がついているので個別パッケージをインストールする必要はない。またREST APIベースでかなり簡単に実装できる。
実装例は以下。
まず、hc
という関数に、フェッチしたいAPIルーターの型をジェネリクスとして渡し、クライアントオブジェクトを作成する。
そして、API接続したい箇所でこのクライアントオブジェクトを呼び出す。
構成は色々あると思うが、今回は、
- サーバー側の
api/src/apps/router/<domain名>/honoClient.ts
で各APIのクライアントオブジェクトを生成する関数をエクスポートする - クライアント側でサーバーのエンドポイントURLを設定し、クライアントオブジェクトを生成する
という具合にしている。
クライアント側でhc
とフェッチしたいルーターをそれぞれインポートするのがちと面倒だったので、サーバ側でクライアントオブジェクトを生成→エクスポートした、というわけだ(ものぐさ)。
import { hc } from "hono/client";
import { deleteBookInfoRoute, getBookByIdRoute, getBookListRoute, postBookInfoRoute, putBookInfoRoute } from ".";
/** GET /api/book/list - クライアントオブジェクト生成関数 */
export const getBookListRouteClient = (url: string) => hc<typeof getBookListRoute>(url);
/** GET /api/book/{id} - クライアントオブジェクト生成関数 */
export const getBookByIdRouteClient = (url: string) => hc<typeof getBookByIdRoute>(url);
import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
import Typography from "@mui/material/Typography";
import { useSuspenseQuery } from "@tanstack/react-query";
import { getBookListRouteClient } from "api/src/apps/router/book/honoClient";
import { useEffect } from "react";
export const Top = () => {
/** GET /api/book/list API呼び出し */
const getBookList = async () => {
// hono client生成
const client = getBookListRouteClient("http://localhost:7071");
const res = await client.api.book.list.$get();
return res.json();
};
// API呼び出し
useEffect(() => {
const fc = async () => {
const res = await getBookList();
console.log("[res]", res);
};
fc();
}, []);
/** API呼び出し(Tanstack Query使用) */
const { data } = useSuspenseQuery({
queryKey: ["getBookList"],
queryFn: getBookList,
});
return (
<>
<Typography variant="h6">トップページ</Typography>
<Typography variant="body1">APIテスト</Typography>
{/* APIテスト結果 */}
<Table sx={{ width: "500px" }}>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>名称</TableCell>
</TableRow>
</TableHead>
{data?.map((item) => (
<TableBody key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.label}</TableCell>
</TableBody>
))}
</Table>
</>
);
};
初見だととっつきづらいのはconst res = await client.api.book.list.$get();
の部分かと思う。これは、APIパスをピリオド区切りにして、$
以下でHTTPメソッドを指定しているイメージ。
(上記例は GET /api/book/list
へのリクエスト)
実はエディタ上で自動で補完してくれるので、脳死でも書けてしまう🌸
やっていることとしては、各APIに対応したクライアントオブジェクトに、リクエスト情報を都度セットしてあげているだけ。慣れてしまえばどうってことない。
そして何より、リクエスト・レスポンス部分も型定義を補完してくれている。res
にカーソルを当てればこんな感じで型定義を出してくれる。
今までの開発だと、APIリクエスト/レスポンスの型/スキーマ定義を、フロント側でも個別定義していた。
型の共有ができないので仕方ないとはいえ、両方の定義をきちんと合わせるのが完全手作業で、ヒューマンエラーが起こりがちだった。何よりクソ面倒だった。
それがどうだ、このRPC機能を使えば、
API接続用のオブジェクトをサーバー側で生成しているので、サーバー側のスキーマ定義をクライアント側でも利用できている、それも自動で。
しかも、APIパスもクライアントオブジェクトに含まれているので、パス指定時でミスることもない。 エディタが補完してくれるし、万が一typoしてもエディタが怒ってくれるからめちゃくちゃ安全。
フロント側で個別リクエスト/レスポンスのスキーマ/型定義を行う必要がないから、記述量が格段に減少。
API側の型定義が変わったからフロント側の型定義も変える……みたいな煩雑な対応が不要になり、メンテコストも低減!
爆速で型安全なAPI通信を実装できた…………!
終わりに
無事にAzure Functions + Hono でバックエンドAPIを構築することに成功。
FastAPIで実現できていたドキュメント自動生成機能も担保しつつ、RPC機能等で開発体験も爆上がりした。
他メンバーからも「便利でいい(特にRPC機能)」と好評。
筆者が普段はフロントメインで対応していることもあり、今回はフロント開発体験に大きく寄与しそうな部分だけを取り上げたが、
Honoには他にも面白そうな機能がある&日々アップデートされているので、今後も動向を追いかけつつ色々取り入れてみたい…………。