AIつまみアイコンを支える技術 (Webアプリ実装編)

フロントエンド・バックエンドの実装の話

投稿日
更新日
読了予想時間
25
tag emoji技術

フロントエンドでの処理

フロントエンドはユーザーが「見る」部分です。 「見た目」「操作性」「動き」など、ユーザーが直接触れる部分を担当します。

つまみネットでは React のフレームワークである Next.js を使ってフロントエンドを実装しています。

トップページ

再掲: つまみアイコンのトップページの『AIつまみアイコン』コンポーネントのスクリーンショット

画像を表示するカードは次のような流れで表示しています。

  • バックエンドに最新の画像を取得するリクエストを送り、画像を表示
  • 画像を更新するリクエストを送る
    • このとき最小アップデート間隔を超えていたら画像を更新する

シーケンス図は以下の通りです。

Loading diagram...

このシーケンス図は、処理の流れを図で表したものです。

メタデータ

シーケンス図にあるメタデータとはなんでしょう? ここでは「画像生成に用いたプロンプト」「生成日」「画像のURL」などの情報を格納しています。 つまり、画像に関する様々な情報が入ったデータのことです。

実際にリクエストを送ってメタデータがどんなものか見てみましょう。

Shell
curl -X GET https://api.trpfrog.net/icongen/current/metadata
Request
{
  "id": "0193e77d-56f9-7fb3-8986-3ae9861c74b5",
  "prompt": {
    "author": "gpt-4o-2024-11-20",
    "text": "an icon of trpfrog bouncing on a swan with rice and eggshells",
    "translated": "つまみさんが白鳥に乗って弾みながら、米と卵の殻に囲まれている画像"
  },
  "modelName": "Prgckwb/trpfrog-sd3.5-large-lora",
  "createdAt": "2024-12-21T04:32:50.580Z",
  "imageUri": "https://media.trpfrog.net/ai-icons/2024-12-21-04-32-50.jpeg"
}

この情報を使って画像を表示しています。 いわゆる JSON 色付け職人の作業です。私は JSON 色付け大好きです。一番楽しいかも、成果を目で確認できるのが良い!

そんなことを言っていますが、最近はリファクタリングばかりやっているらしいです。 リファクタリングも楽しい!テスト書かずに大規模リファクタするのやめてね。やめてくれてありがとう。

画像更新のリクエスト

実は画像更新のためのリクエストはサイト訪問者が送るようになっています。 つまみネットは訪問者が非常に少ないサイトです (😢)。そのため、頻繁に更新すると、無駄にAI画像生成APIの利用回数 (API Quota) を消費してしまいます。 これを避けるため、サイト訪問者のブラウザ側から画像更新のリクエストを送るようにしています。リクエスト時、前回の生成から3時間経過していたら画像を更新します。

という話を悪いオタクにしたら「頻繁にアクセスするbotを作るぞ!」と言っていて最悪になりました。

リクエストは /icongen/update に POST リクエストを送ると更新リクエストができるようになっています。 /icongen/update?force=true とすると無条件で更新されます。 ただ、この API を一般開放してしまうと、誰かが無限にリクエストを送りつける可能性があるので API キーで保護しています。

バックエンド側のコードを見てみましょう。 requiresApiKey() というミドルウェアで保護しています。

バックエンドのコード
.post('/update', requiresApiKey(), async c => { 
  const result = await ucs.refreshImageIfStale({
    forceUpdate: c.req.query('force') === 'true',
  })

これで /update は悪用されにくくなって安心!と思うのですが、このように /update 全体を保護してしまうと一般の利用者も使えなくなってしまいます。 そこで今回は BFF (Backend for Frontend, フロントエンドとバックエンドの間に入る調整役的なやつ) を使って一般の利用者向けのエンドポイントを作りました。

Loading diagram...

つまみネットにおける BFF は Next.js のサーバ側で動いている API です。 後で説明しますが、バックエンド (≠ BFF) は Cloudflare Workers 上で動いているサーバで、つまみネット本体とは別のサービスです。

BFFのコード
'use server'

export async function requestUpdateIcon() {
  const apiKey = process.env.TRPFROG_FUNCTIONS_SECRET
  // API キーを使ってリクエスト
  await client.update.$post({ header: { 'x-api-key': apiKey } }) 
}
ブラウザ側のコード
import { useEffect } from 'react'
import { requestUpdateIcon } from './actions'

export function IconFrame() {
  // 略

  // 更新リクエストを送る
  useEffect(() => { 
    requestUpdateIcon().catch(console.error) 
  }, []) 

  // 略

  return (
    <figure>
      {/* 略 */}
    </figure>
  )
}

このようにサーバ側の BFF で API キーを使ってリクエストし、ブラウザ側からは API キーを使わずにリクエストを送ることができます。

BFF は React 19 の Server Action というものを使って作りました。 Server Actions はサーバサイドで実行される関数を簡単に書ける機能です。この関数はビルド時に API エンドポイントに変換されるため、サーバサイドでの処理を簡単に実行できます。 (GraphQL みたいな感じでエンドポイントひとつで、クエリによって処理を変えているっぽいです) つまり、BFF エンドポイント呼び出しを、まるでただの関数呼び出しであるかのように書くことができるのです。便利!

ただ便利ではあるのですが、API エンドポイントに変換される仕様であることを知らないとセキュリティ的に問題のあるコードを書いてしまいそうです。 まず Server Actions の引数には悪いユーザからの入力が直接やってくる可能性があります。きちんとバリデーションやエスケープを行う必要があります。 今回の requestUpdateIcon は引数がないので問題ありませんが、後述する引数のある Server Action ではきちんとバリデーションを行いました。

ところで BFF を使わなくても、?force=true のときだけ API キーを要求するようにすることもできます。というか多分そっちの方がシンプルな感じはします。 今回はとりあえずガチガチに /update を保護しておいて、BFF でホワイトリスト制 (?) にした方が、実装ミスからくるセキュリティホールを防げるかなと思ってこのようにしてみました。どっちの方がいいんでしょう?

画像一覧ページ

再掲: AIつまみアイコンの一覧ページ

画像一覧ページは情報を取ってくるだけなのでシンプルです。

  • ブラウザから BFF に向けて全件取得リクエストを送る
  • BFF がバックエンドにリクエストを送り、画像のメタデータ一覧を取得
  • 画像一覧を表示

シーケンス図は以下の通りです。

Loading diagram...

BFF で /icongen/query をラップする

本来、/icongen/query は複雑なクエリも受け入れられるエンドポイントで、かつ API キーが必要なエンドポイントです。 画像一覧を返す用に API キーの必要ない /icongen/all のようなものを作っても良いのですが、これは完全にフロントエンド側の都合な気がしたのと、実装コストがかかるため、できればまとめてしまいたい気持ちがありました。そこで Server Actions による BFF を用意しました。

バックエンド側の定義はこのようになっています。 クエリパラメータとして q (検索ワード)、limit (取得する数)、offset (何番目以降のデータを取得するか) を受け取ります。例えば GET /icongen/query?q=星空&limit=10&offset=20 とすると、プロンプトに「星空」という単語が含まれる画像のうち、21番目から30番目までのメタデータを取得します。

バックエンドのコード
.get(
  '/query',  
  requiresApiKey(), 
  zValidator(
    'query',
    z.object({
        // 検索ワード
        q: z.string().optional(),  
        // 取得する数
        limit: z.coerce.number().int().positive().max(100).optional(), 
        // 何番目以降のデータを取得するか
        offset: z.coerce.number().optional(),  
      })
      .strict(),
    (c) => {

/icongen/query はできることが多すぎるので、フロントエンド用に機能を制限した BFF を作りました。 ここでは 1 ページあたり最大 20 件に絞って結果を返すようにしています。

Server Action はクライアントサイドから自由に呼び出されるため、入力できる値は制限するべきです。 ここでは「ページ番号は正の整数に限る」というバリデーションを Zod でやっています。

BFFのコード
'use server'
import { z } from 'zod' // データの形式を検証するためのライブラリ

// バリデータ
const fetchImageRecordsQuerySchema = z.object({
  // ページ番号
  page: z.number().int().positive(),  
  // 1ページあたりの表示件数 (デフォルト 20)
  iconsPerPage: z.number().int().positive().max(20).default(20),  
})

// クエリの型
export type FetchImageRecordsQuery = z.input<typeof fetchImageRecordsQuerySchema>

// Server Action
export async function fetchImageRecords(rawQuery: FetchImageRecordsQuery) {
  // バリデーション (この関数はユーザから直接叩かれる可能性があるため)
  const query = fetchImageRecordsQuerySchema.parse(rawQuery)   

  // BFF からバックエンドにリクエストを送る
  const { result, total } = await client.query   
    .$get({
      query: {
        limit: query.iconsPerPage.toString(),
        offset: ((query.page - 1) * query.iconsPerPage).toString(),
      },
      header: {
        'x-api-key': env.TRPFROG_FUNCTIONS_SECRET ?? '',
      },
    })
    .then(res => res.json())

  // 結果の配列, 合計レコード数, ページ数
  return { result, total, numPages: Math.ceil(total / NUM_ICONS_PER_PAGE) }
}

そして最後にクライアントサイドから BFF (Server Action) を叩いて画像一覧を取得します。

ブラウザ側のコード
import { useCallback } from 'react'
import useSWR from 'swr' // データを効率的に fetch するためのライブラリ
import { IconRecord } from './IconRecord'
import { fetchImageRecords, FetchImageRecordsQuery } from './actions'

// 画像一覧を取得
function useImageRecords(query: FetchImageRecordsQuery) {
  // BFF を叩く関数
  const fetcher = useCallback(() => fetchImageRecords(query), [query])   
  const key = `useImageRecords-${JSON.stringify(query)}`

  // 実際にデータをとってくる
  return useSWR(key, fetcher, {
    // 前に取得したデータを保持する (ちらつき防止)
    keepPreviousData: true,
  })
}

// アイコン一覧を表示するコンポーネント
function Icons() {
  const page = ...; // ?page=1 で表されるページ番号を URL から取得

  // 画像一覧を取得
  const res = useImageRecords({ page })   
  if (res.error) return <div>Error</div>
  if (res.isLoading && !res.data) return <div>Loading...</div>

  // 画像一覧
  const images = res.data.result

  // 画像一覧を表示
  return (
    <div className="tw-flex tw-flex-col tw-gap-4">
      {images.map(image => (
        <div>
          <img src={image.imageUri} alt="AI生成画像" aria-describedby={`prompt-${images.id}}`} />
          <div id={`prompt-${images.id}}`}>
            <p>{image.prompt.text}</p>
            <p>{image.prompt.translated}</p>
          </div>
        </div>
      ))}
    </div>
  )
}

こんな感じで実装すると、画像一覧ページを作れます。

話は変わるのですが AI 生成画像をプロンプトと共に載せる場合 alt って何書けば良いんですかね? プロンプトは既に載っているので alt に載せるのは違う気がするし……今回は aria-describedby で紐づけてみましたが合っているか分かりません。生成後の画像を GPT-4o にぶん投げて alt テキスト自動生成してもらうのも考えましたが、嘘 alt が生成される可能性があるのでやめました。それを考えると aria-describedby をつけるのも不適切な気がしてきました……。難

記事一覧ツイート訂正リクエスト
タグ「技術」の新着記事