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

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

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

バックエンド: 依存性注入

今回バックエンドの構築にクリーンアーキテクチャっぽいものを導入してみました。 たぶんクリーンアーキテクチャと言うと怒られる (※) ので、依存性注入 (dependency injection) をやったよ!と言う程度に言っておきます。

注意: 初心者です

※クリーンアーキテクチャをきちんと体系的に勉強したわけではないので間違っていることを言っている可能性があります。早くしっかり勉強しよう!

依存性注入とは

依存性注入 (dependency injection)とは、部品を交換可能にして、コードを整理しやすくする設計方法です。 例えば、車の部品を交換するように、データベースの種類を簡単に変えることができるようになります。

クリーンアーキテクチャとは

クリーンアーキテクチャは、ソフトウェアを「中心に大事なルールや動き、外側に道具や技術」という形で整理する考え方です。これにより、仕組みが分かりやすくなり、修正や新しい機能の追加がしやすくなります。

自分はこういうイメージで使いました。

Loading diagram...

ディレクトリ構成

画像生成サービス (api.trpfrog.net/icongen) は次のようなディレクトリ構成になっています。 クリーンアーキテクチャを意識しています。

.
├── README.md
├── drizzle.config.ts
├── eslint.config.mjs
├── package.json
├── bin/
├── coverage/
├── migrations/
├── src
│   ├── index.ts     # サービスのエントリーポイント
│   ├── client.ts    # 他のサービスからこれを叩くための RPC クライアントを export している
│   ├── env.ts       # Hono の Bindings などを定義している
│   ├── wire.ts      # 依存性注入をする
│   ├── lib/         # 雑多な便利関数など
│   ├── controller/  # Hono のルータが入っている
│   ├── domain/      # このサービス内で使うインタフェース等のドメインモデルが入っている
│   ├── infra/       # このサービスが外部とやり取りするためのインフラ実装が入っている
│   └── usecases/    # ユースケースが入っている
├── tsconfig.json
├── vitest.config.mjs
├── worker-configuration.d.ts
└── wrangler.toml

コントローラ層

コントローラ層とは、ユーザーからのリクエストを受け取り、処理を振り分ける場所です。 ここでは Web フレームワークである Hono を使っています。

例えば src/controller には次のような実装が入っています。

src/controller/index.ts
export function createApp(ucs: UseCases) {
  return new Hono<Env>()
    .basePath(services.imageGeneration.basePath)
    .use(contextStorage())
    .use(prettyJSON())
    .use(trimTrailingSlash())
    .use(cors())
    .get('/current', async c => {
      const arrayBuffer = await ucs.currentImage() 
      return c.newResponse(arrayBuffer)
    })
    .get('/current/metadata', async c => {
      const data = await ucs.currentMetadata() 
      if (data == null) {
        return c.json({ error: 'No metadata found' }, 404)
      }
      return c.json(data)
    })
    .post('/update', requiresApiKey(), async c => {
      const result = await ucs.refreshImageIfStale({ 
        forceUpdate: c.req.query('force') === 'true',      
      })                                                   
      return result.updated
        ? c.json({ status: 'updated' }, 201) // 201 Created
        : c.json({ status: 'skipped', message: result.message })
    })

ここは実際のルーティングをする層です。 ここにはロジックを書かず、ユースケースを呼び出すだけにしています。 リクエストからデータを取り出して、ユースケースに渡しています。

ここにロジックを書かないことによって、今後 Web フレームワークを変更する機会があっても、ルーティングとデータ加工の部分だけを置き換えるだけで移行が完了します。 ロジックのことを考えて移行をしなくて良いのがメリットです。

ユースケース層

ユースケース層とは、サービス内で使う純粋なロジックを行う場所です。使う外部サービス等に依存しないロジックを書きます。ビジネスロジックと言ったりします。

あとで説明するドメイン層で定義した型・インタフェースを使ってロジックを書くので、外部サービスに依存しません。

次の例は画像を DB とオブジェクトストレージにアップロードするためのユースケースです。

src/usecases/uploadNewImageUseCase.ts
interface UploadNewImageUseCaseDependencies {
  imageStoreRepo: ImageStoreRepo
  imageMetadataRepo: ImageMetadataRepo
}

export function uploadNewImageUseCase(deps: UploadNewImageUseCaseDependencies) {
  return async (
    imageData: ArrayBuffer,
    metadata: {
      prompt: ImagePrompt
      modelName: string
      createdAt?: Date
      imageExtension: string
    },
  ) => {
    // ここからロジック
    const { prompt, modelName, createdAt = new Date(), imageExtension: ext } = metadata
    const filename = `${format(createdAt, 'yyyy-MM-dd-HH-mm-ss')}${ext}`

    const imageUri = await deps.imageStoreRepo.upload(filename, imageData)
    try {
      const metadataRecord = { id: uuidv7(), prompt, modelName, createdAt, imageUri }
      await deps.imageMetadataRepo.add(metadataRecord)
    } catch (e) {
      console.error(e)
      await deps.imageStoreRepo.delete(filename)
      throw e
    }
  }
}

ここでは

  • 画像データとメタデータを受け取る
  • 画像データをオブジェクトストレージにアップロード
  • 画像URLとIDを含めたメタデータを DB に保存
  • 途中でエラーが発生した場合はロールバック

という処理を書いています。ここで、画像ストアやメタデータストアの具体的な実装は引数 deps によって渡されます。 具体的な実装を uploadNewImageUseCase に渡すと、"依存"の注入されたユースケース本体の関数が得られます。

src/usecases/uploadNewImageUseCase.ts
export function uploadNewImageUseCase(deps: UploadNewImageUseCaseDependencies) {
  return async (...args) => {
    // 依存注入後の関数
  }
}

この deps: UploadNewImageUseCaseDependencies の一部である ImageStoreRepo の定義 (ドメイン層) は次のようになっています。 ドメイン層とは、このサービスで扱う中心的なデータやルールを定義する場所です。

src/domain/repos/imageStoreRepo.ts
export interface ImageStoreRepo {
  upload: (filename: string, imageData: ArrayBuffer) => Promise<string> // 戻り値はURI
  download: (filename: string) => Promise<ArrayBuffer> // 画像データを取得
  delete: (filename: string) => Promise<void> // 画像データを削除
}

このようにインターフェースのみが書かれていることが分かります。 DBやオブジェクトストレージに関する実装は含まれておらず「upload を呼べばアップロードできる」「download を呼べばダウンロードできる」ということだけが書かれています。 このようにすることで、ロジックと使う DB などのインフラを分離できます。

私は一度、つまみネットにツイート一覧をみるページを作ったことがあるのですが、使っていた PlanetScale という DB のサービスの無料プランが終了したことから、DB の移行を余儀なくされました。しかし、このようにインフラとロジックがガチガチに結びついていたために改修がめんどくなり、今も移行を放置している状況です……。このような状況を回避するためにも依存性注入は重要だと思っています。

インフラ層

インフラ層とは、データベースや外部サービスなど、具体的な技術の実装を行う場所です。

ユースケース層では、ドメイン層で定義したインターフェースを使ってロジックを書きました。 今度は DB などの外部サービスを "ドメイン層に合わせるように" 実装します。

今回は画像ストアには Cloudflare R2 (オブジェクトストレージ) を使っています。次のような実装になっています。

src/infra/repos/imageStoreRepo.ts
import { getContext } from 'hono/context-storage'
import { ImageStoreRepo } from '../../domain/repos/image-store-repo'

export const imageStoreRepoCloudflareR2: ImageStoreRepo = { // ImageStoreRepo を実装
  upload: async (filename: string, imageData: ArrayBuffer) => {
    const c = getContext<Env>()
    const filePath = toR2Key(filename)
    await c.env.BUCKET.put(filePath, imageData)
    return `https://media.trpfrog.net/${filePath}`
  },

  download: async (filename: string) => {
    const c = getContext<Env>()
    const filePath = toR2Key(filename)
    const res = await c.env.BUCKET.get(filePath)
    if (res === null) {
      throw new Error('Not found')
    }
    return await res.arrayBuffer()
  },

  delete: async (filename: string) => {
    const c = getContext<Env>()
    const filePath = toR2Key(filename)
    await c.env.BUCKET.delete(filePath)
  },
}

これを uploadNewImageUseCase に渡すことで、画像のアップロードができるようになりました。 このように依存を注入するアーキテクチャを使うことで「コントローラ」「ロジック」「インフラ」の実装を分離してあげることができて嬉しいです。

インフラ部分を分けることにはもう一つ嬉しいことがあって、テストが描きやすくなると言うメリットがあります。 例えば、uploadNewImageUseCase のテストをしたい場合は、要求している依存のインタフェースを実装したダミーオブジェクトを注入してあげると、 実際の DB とやり取りせずに、純粋なロジックのテストが可能になります。依存性注入で一番嬉しかったポイントです。

また、ユースケースのテストだけでなく、コントローラのテストにもこの手法は使えるので、モックに頭を悩ますことが減ってとても楽になりました。

hono/context-storage

Cloudflare D1 や R2 は "Bindings" として提供されています。

Bindingsの例
const app = new Hono()
  .get('/current', async c => {
    // `c.env.BUCKET` で R2 にアクセスできる
    const arrayBuffer = await c.env.BUCKET.get('current')  
    return c.newResponse(arrayBuffer)
  })

せっかく Bindings から簡単にアクセスできるようになっているのに、このように Hono のコントローラ部分とインフラ部分を分けてしまうと Bindings を使うことが難しくなってしまいます。

そこで最近 Hono に追加された hono/context-storage を使うと簡単に Bindings にアクセスできるようになりました。

controller.ts
import { contextStorage } from 'hono/context-storage'

const app = new Hono()
  .use(contextStorage()) 
  .get('/current', async c => {
    // ...
  })
infra.ts
import { getContext } from 'hono/context-storage'

export const imageStoreRepoCloudflareR2: ImageStoreRepo = {
  upload: async (filename: string, imageData: ArrayBuffer) => {
    const c = getContext<Env>() 
    const filePath = toR2Key(filename)
    await c.env.BUCKET.put(filePath, imageData)
    // ...
  },
  // ...
}

今回分離する上でとても助かりました!便利〜

全部をつなげる

現状では

  • ユースケース層はドメイン層のインターフェースを使っている
  • インフラ層はドメイン層のインタフェースを見ている
  • コントローラはユースケース層の型だけを見ている

という状況で、実装が注入されていません。という虚像だけを見ている状態なので、うまく繋げてあげる必要があります。

今回私はコントローラを作るための関数、createApp を次のように作りました。

src/controller/index.ts
import { UseCases } from '../wire'

export function createApp(ucs: UseCases) {
  return new Hono<Env>()
    // ...
    .get('/current', async c => {
      const arrayBuffer = await ucs.currentImage()
      return c.newResponse(arrayBuffer)
    })
    // ...
}

依存注入後のユースケース一覧を引数として与えると、依存の注入が完了したコントローラができます。

ユースケースへの依存注入は気合いでやります。

気合いで依存注入
src/index.ts
import { z } from 'zod'

import { createApp } from './controller'
import { prepareUsecasesBuilder } from './wire'

// インフラ層の実装
import { imageMetadataRepoCloudflareD1WithKV } from './infra/repos/imageMetadataRepoCloudflareD1WithKV'
import { imageStoreRepoCloudflareR2 } from './infra/repos/imageStoreRepoCloudflareR2'
import { createOpenAIChatLLMJson } from './infra/services/llm'
import { randomWordApi } from './infra/services/random-words'
import { createHfImageGenerator } from './infra/services/text-to-image'

const env = z
  .object({
    HUGGINGFACE_TOKEN: z.string(),
    OPENAI_API_KEY: z.string(),
  })
  // eslint-disable-next-line n/no-process-env
  .parse(process.env)

const app = createApp(
  prepareUsecasesBuilder({
    imageStoreRepo: imageStoreRepoCloudflareR2,
    imageMetadataRepo: imageMetadataRepoCloudflareD1WithKV,
    textToImage: createHfImageGenerator({
      modelName: 'Prgckwb/trpfrog-sd3.5-large-lora',
      hfToken: env.HUGGINGFACE_TOKEN,
    }),
    jsonChatbot: createOpenAIChatLLMJson({
      model: 'gpt-4o-2024-11-20',
      temperature: 0.9,
      apiKey: env.OPENAI_API_KEY,
    }),
    generateSeedWords: () => randomWordApi(10),
  })
)
src/wire.ts
import * as rawUsecases from './usecases'

// 共通の依存関係を受け取る型を定義
type CommonDependencies = {
  imageStoreRepo: ImageStoreRepo
  imageMetadataRepo: ImageMetadataRepo
  textToImage: TextToImage
  jsonChatbot: ChatLLMJson
  generateSeedWords: () => Promise<string[]>
}

// ユースケースを手動で組み立てる関数
export function prepareUsecasesBuilder(common: CommonDependencies): UseCases {
  const { imageStoreRepo, imageMetadataRepo, textToImage, jsonChatbot, generateSeedWords } = common

  // 基本的なユースケースのインスタンスを作成
  const currentImage = rawUsecases.currentImageUseCase({ imageMetadataRepo, imageStoreRepo })
  const currentMetadata = rawUsecases.currentMetadataUseCase({ imageMetadataRepo })
  const generateImage = rawUsecases.generateImageUseCase({ textToImage })
  const generatePromptFromWords = rawUsecases.generatePromptFromWordsUseCase({ jsonChatbot })
  const generateRandomWords = rawUsecases.generateRandomWordsUseCase({ generateSeedWords })
  const uploadNewImage = rawUsecases.uploadNewImageUseCase({ imageMetadataRepo, imageStoreRepo })
  const queryImageMetadata = rawUsecases.queryImageMetadataUseCase({ imageMetadataRepo })

  // 依存関係を持つ派生ユースケースのインスタンスを作成
  const generateRandomImage = rawUsecases.generateRandomImageUseCase({
    generateImage: (prompt: string) => generateImage(prompt, { numberOfRetries: 3 }),
    generatePromptFromSeedWords: (seedWords: string[]) => generatePromptFromWords(seedWords),
    generateSeedWords: generateRandomWords,
  })

  const refreshImageIfStale = rawUsecases.refreshImageIfStaleUseCase({
    imageMetadataRepo,
    uploadImage: uploadNewImage,
    imageGenerator: generateRandomImage,
  })

  return {
    currentImage,
    currentMetadata,
    generateImage,
    generatePromptFromWords,
    generateRandomWords,
    uploadNewImage,
    queryImageMetadata,
    generateRandomImage,
    refreshImageIfStale,
  }
}

実際はこれは嘘で、もう少し簡単に書けるようなライブラリを作って使っています。

とはいえ試作段階であまり納得いってないのでもっと別の方法を探しています。 やはり大人しく DI コンテナ (依存性注入を自動化するやつ) のライブラリを導入すべきか……自動生成コードのアンチなので、TypeScript の型をうまく使って快適に依存注入を書く方法がないか考えています。 とはいえ型はランタイムに干渉できないので、自動生成に手をつけるしかないのか、グヌヌ……

メリットとデメリット

ということで画像生成サービスにクリーンアーキテクチャ (だと思う) を導入してみました。

さっきの図のようなことができるようになりました。

Loading diagram...

メリットとして、クリーンアーキテクチャのフレームワークに乗っかったことによって自然と疎結合なコードを書けるようになったことが挙げられます。

始めはコントローラ・ドメイン・インフラで型を共有しないことに DRY っぽさを感じて嫌だったのですが、よく考えれば外部サービスや外部ライブラリに依存する型は、今後の改修によって変わってしまう可能性があります。Request で受けたデータをドメイン層の型に詰め替える作業をすることで、外部の依存の変更に対しても柔軟に対応できるようになったんじゃないかなあと思います。PlanetScale 無料枠サ終からの移行困難という悲劇を経験した私にとっては、かなり嬉しい気持ちがあります。

デメリットとしては実装コストがデカすぎることが挙げられます。 もう Hono のルータの中に全部書いてしまえば良さそうなものを、わざわざ別ファイルに実装することは効率の悪さを感じます。 ただ、これは最初の実装時に感じるつらさであって、メンテ時はこの構造のおかげで楽をできるようになるはずなのでやって良かったと思っています。たぶん、多分やって良かったです。

今受けられている大きな恩恵としては、先ほども書きましたがテストを書きやすくなったことです。今までは vi.spy とかして無理やりモックしていた箇所を、正当な方法でモックできるようになったのが嬉しいです。わりとこれだけでもやった価値はあったと思います。

まとめ

この記事では、AIによる自動生成アイコン「AIつまみアイコン」を支えるWebアプリの仕組みをざっくり紹介しました。まず、フロントエンドはNext.js (App Router)とReact、Tailwind CSSを組み合わせて構築し、サイトの訪問時にアイコンの自動更新をリクエストしてAPIキーの乱用を防ぐ工夫をしています。Server Actionを使い、BFF的にAPIキーを隠せるのがポイントです。

一方、バックエンド側ではCloudflare Workers上でHonoを使い、D1やR2、Workers KVなど複数のデータストアやストレージを組み合わせて画像とメタデータを管理しています。画像生成のためのプロンプトはGPT-4oを活用し、ランダムな英単語からユーモアあるアイコンを生み出す仕組みにしています。さらに、クリーンアーキテクチャっぽい構成を採用し、依存性注入を取り入れることでテストや将来の拡張がしやすくなるように設計しています。

まだ趣味的な取り組みの段階ですが、使いながら「こうした方が便利!」と感じた要素をどんどん改善しているところです。自分でやってみると意外と面倒な部分が浮き彫りになるので、そこを試行錯誤できるのが個人開発の楽しさかもしれません。

と Gemini 2.0 Flash Experimental が言ってました。あと9分で12月22日が終わるらしいです。まずい!

Future Work

  • アーキテクチャの整理 今回クリーンアーキテクチャっぽいことをしましたが、しっかり勉強したわけではないのできちんと勉強してから改善を進めたいです。
  • 検索機能の追加 一応プロンプト検索の機能はバックエンド側に実装していますが、フロントエンド側での実装がまだです。 画像が増えてくると検索機能も欲しくなりそうなので、そのあたりも考えていきたいです。
  • つまみアイコン判定モデルを作る 現状ではうまくつまみさんが生成されないことがあります。ちくわぶさんと協力してアノテーション祭りしようかなあと思ってます。マジで?

つまみネットはデカすぎるので個人でメンテできる範囲を超えてきています。 しょうもない実装を見つけたら PR を投げてくれると喜びます。よろしくお願いします。


記事が大きくなってしまいましたが、実はこの記事は UEC Advent Calendar 2024 22日目の記事でした。

明日はひたかくしさんの『今年の制作物発表会 〜お前大学以外で何してるの〜』です。ものづくりは楽しいですからね。お楽しみに!

adventar.org
UEC Advent Calendar 2024 - Adventar
# 電通大生による電通大生のためのAdvent Calendar 2024 ## これはなに? 12/1から12/25のクリスマスまで、毎日電通大生の有志が記事を書いて、カレンダーを電通大に染める企画です。例年、知らぬ間に有志によって動いています。 早々に埋まったので[その2](https://adventar.org/calendars/10198)も作りました。 ## 何を書けばいいの? 技術的なお話はもちろん、真面目なネタやオタクなネタでも何でもいいです。なんなら作り話でもなんでもいいです。後ろにあるリンクから過去のアドベントカレンダーを見れば雰囲気がわかります。 ## 誰が書けるの? 電通大生っぽかったら、誰でもいいです。電通大生っぽかったら、誰でもいいんですよ。 ## 参加したいが、ネタがないです まずは参加登録してから悩みましょう。あなたの身の回りには、知らぬ間にネタで溢れかえっています。学校のことやバイトのこと、生涯を誓った推しのことやライフハックなど、書けそうって思えるものならなんでもOKです。 過去のAdvent Calendar [UEC Advent Calendar 2013](https://adventar.org/calendars/113) [UEC Advent Calendar 2014](https://adventar.org/calendars/335) [UEC Advent Calendar 2015](https://adventar.org/calendars/726) [UEC Advent Calendar 2016](https://adventar.org/calendars/1953) [UEC Advent Calendar 2017](https://adventar.org/calendars/2376) [UEC Advent Calendar 2018](https://adventar.org/calendars/3569) [UEC Advent Calendar 2019](https://adventar.org/calendars/4393) [UEC Advent Calendar 2020](https://adventar.org/calendars/5070) [UEC 2 Advent Calendar 2020](https://adventar.org/calendars/5276) [UEC Advent Calendar 2021](https://adventar.org/calendars/6400) [UEC 2 Advent Calendar 2021](https://adventar.org/calendars/6598) [UEC Advent Calendar 2022](https://adventar.org/calendars/7581) [UEC 2 Advent Calendar 2022](https://adventar.org/calendars/7586) [UEC Advent Calendar 2023](https://adventar.org/calendars/8698) [UEC 2 Advent Calendar 2023](https://adventar.org/calendars/8704) ### P.S. 2025年のUEC Advent Calendarを作ろうとしているそこのあなた 開発者ツールでheadタグの中を見るとMarkdownが載ってるからコピペしてね

おまけ: Server Actions を curl から叩いてみよう

Server Actions はユーザから叩かれる危険があるという話をしたので、実際に curl で叩いてみます。 あくまでセキュリティ意識を高めてもらうためのおまけですので、悪用は厳禁です。やめてください。

まず、Chrome の DevTools を開いて Network タブを開きます。次に Server Actions が叩かれるページを開きます。 つまみネットのアイコン生成結果一覧のページが Server Actions を叩いているので、今回はそこにします。

通信履歴をみているとそれっぽそうなリクエストが見つかります。見てみましょう。

リクエストヘッダ
POST /ai-icons?page=1 HTTP/1.1
Accept: text/x-component
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Connection: keep-alive
Content-Length: 12
Content-Type: text/plain;charset=UTF-8
DNT: 1
Host: localhost:3000
Next-Action: 4095e2f27064b885a81d811909608cea68ed03a177
Next-Router-State-Tree: /* 略 */
Origin: http://localhost:3000
Referer: http://localhost:3000/ai-icons?page=1
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
リクエストボディ
[{"page":1}]   

怪しい情報が出てきました。

  • POST /ai-icons?page=1 へのリクエストである
  • Next-Action というヘッダが付いている
  • リクエストボディには Server Action の引数っぽい JSON が入っている

実際この Server Action は次のように定義されています。

TypeScript
export async function fetchImageRecords(query: { page: number })

ちょうどこれが Server Actions を叩くのに必要な情報っぽく見えます。 これを使って curl でリクエストを送ってみます。

Shell
curl -X POST http://localhost:3000/ai-icons \
  -H "Content-Type: text/plain;charset=UTF-8" \
  -H "Next-Action: 4095e2f27064b885a81d811909608cea68ed03a177" \
  -d '[{"page":1}]'

これを送ってみると次のようなレスポンスが返ってきます。

レスポンス
0:{"a":"$@1","f":"","b":"development"}
1:{
  "result": [
    {
      "id": "0193ea5d-3bad-78ec-ba88-0fc65fea24be",
      "prompt": {
        "author": "gpt-4o-2024-11-20",
        "text": "an icon of trpfrog balancing saddlebags in a neon-lit dystopian world",
        "translated": "ネオンに照らされたディストピア世界で鞍袋をバランスよく持つつまみさんの画像"
      },
      "modelName": "Prgckwb/trpfrog-sd3.5-large-lora",
      "createdAt": "2024-12-21T17:56:37.503Z",
      "imageUri": "https://media.trpfrog.net/ai-icons/2024-12-21-17-56-37.jpeg"
    },
    /* 以下省略 */
  ],
  "total": 34,
  "numPages": 2
}

どうやらこれが Server Actions でやり取りしているメッセージらしいです。 完全に情報が取れてしまっています。リクエストボディを [{"page":1}] から [{"page":2}] に変えれば次のページの情報も取れると思います。 このように Server Actions はユーザから叩かれると情報が漏れてしまうので、セキュリティには注意が必要です。

……KIN TV〜Ah (省略) KIN TV〜 Oh yeah♪

はい!皆さん〜こんにちは!(省略)KIN TV の(省略)KINです。 今日はこちら!

メロンパン食べてみた!

ちょっと今家にメロンパンがなかったので、今回はこの岩塩で代用します。

それでは、いただきやす!

しょっぱい!しょっぱコレ!しょっぱーい!

えーと、確かにねすごく天然の塩の味で美味しいんですけど、調整が必要かもしれないですねこれは

それではまたお会いしましょう。See you next time!

今日のことわざ

ではさらばじゃ!

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