LoginSignup
1
0
はじめての記事投稿
Qiita Engineer Festa20242024年7月17日まで開催中!

Azure Functions + Hono でバックエンドAPIを作成する

Last updated at Posted at 2024-06-11

この記事の概要

普段はフロントエンジニア🐣の筆者が、訳あって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というパスにGETPOSTリクエストが来たら、関数httpTrigger1を実行する…………という内容になっている。

Azure Functionsだけでルーティング設定を行うのであればこの構成で良い。
が、今回ルーティング設定はAzure functionsではなくHono側で実行したい。
どのパスにどのHTTPメソッドのリクエストが飛んだとて、Honoのアプリをトリガーするようにしたい!!!

ということで、以下のように実装した。

api/src/functions/httpTrigger.ts
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の第二引数optionsroute設定で指定したことで、どんなパスへの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にも反映される。

api/src/apps/schemas/book/index.ts
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>;

api/src/apps/router/book/index.ts
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の公開設定を行っている。

  1. /api/openapiでAPI仕様のオブジェクトを公開
  2. /api/docsへのGETリクエストで、/api/openapiで公開している情報をSwagger UIにし生成

……という流れである。

api/src/apps/main.ts
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にアクセスすると……。

image.png
image.png
image.png

ちゃんとAPI仕様が公開されている!!!
やったねできたね!!!

実装ポイント③ RPC機能

RPC機能を使うと、サーバとクライアントでAPI仕様を共有でき、型安全かつ高速にクライアントとサーバーの通信を実装できる。

類似ツールにtRPCやts-restなどがあるが、HonoにはデフォルトでRPC機能がついているので個別パッケージをインストールする必要はない。またREST APIベースでかなり簡単に実装できる。


実装例は以下。

まず、hcという関数に、フェッチしたいAPIルーターの型をジェネリクスとして渡し、クライアントオブジェクトを作成する。

そして、API接続したい箇所でこのクライアントオブジェクトを呼び出す。


構成は色々あると思うが、今回は、
  1. サーバー側のapi/src/apps/router/<domain名>/honoClient.tsで各APIのクライアントオブジェクトを生成する関数をエクスポートする
  2. クライアント側でサーバーのエンドポイントURLを設定し、クライアントオブジェクトを生成する

という具合にしている。
クライアント側でhcとフェッチしたいルーターをそれぞれインポートするのがちと面倒だったので、サーバ側でクライアントオブジェクトを生成→エクスポートした、というわけだ(ものぐさ)。

api/src/apps/router/book/honoClient.ts
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);
クライアント側のファイル.tsx
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にカーソルを当てればこんな感じで型定義を出してくれる。

image.png

今までの開発だと、APIリクエスト/レスポンスの型/スキーマ定義を、フロント側でも個別定義していた。
型の共有ができないので仕方ないとはいえ、両方の定義をきちんと合わせるのが完全手作業で、ヒューマンエラーが起こりがちだった。何よりクソ面倒だった。

それがどうだ、このRPC機能を使えば、
API接続用のオブジェクトをサーバー側で生成しているので、サーバー側のスキーマ定義をクライアント側でも利用できている、それも自動で。

しかも、APIパスもクライアントオブジェクトに含まれているので、パス指定時でミスることもない。 エディタが補完してくれるし、万が一typoしてもエディタが怒ってくれるからめちゃくちゃ安全。

フロント側で個別リクエスト/レスポンスのスキーマ/型定義を行う必要がないから、記述量が格段に減少。
API側の型定義が変わったからフロント側の型定義も変える……みたいな煩雑な対応が不要になり、メンテコストも低減!

爆速で型安全なAPI通信を実装できた…………!

image.png
ちゃんと通信できてるね!

終わりに

無事にAzure Functions + Hono でバックエンドAPIを構築することに成功。
FastAPIで実現できていたドキュメント自動生成機能も担保しつつ、RPC機能等で開発体験も爆上がりした。
他メンバーからも「便利でいい(特にRPC機能)」と好評。

筆者が普段はフロントメインで対応していることもあり、今回はフロント開発体験に大きく寄与しそうな部分だけを取り上げたが、
Honoには他にも面白そうな機能がある&日々アップデートされているので、今後も動向を追いかけつつ色々取り入れてみたい…………。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0