🗺️

宣言的にAPIとDB操作を書ける仕組みを作った

2024/05/04に公開

favoExtendというシステムを作りました。

https://github.com/nkte8/favoExtend/tree/main

多分ORM(オブジェクト関係マッピング)と定義するには少しレイヤーが高めな気がするのでORMとは名乗っていませんが、だいたいそういうやつだと思います。

どういうもの?

こういうコードを書くと...

favoExtend/apidefs.ts
export const GetFavo = new Definition(
    {
        path: '/favo',
        method: 'GET',
        query: z.object({
            id: z.string().regex(idRule),
        }),
        output: { count: '${#0}' },
    },
    [
        {
            keyRef: 'favo/${#.id}',
            functionName: 'get',
            output: z.number().default(0),
        },
    ],
)

こういうAPIが作れます。

  • 外部仕様

    Path method 入力内容 出力内容
    /favo GET URLクエリへid=<string> 処理成功時: {"count":"処理#0の実行結果"}
  • 内部仕様

    処理 key入力 処理内容 Value入力 処理のオプション 出力の内容
    #0 favo/<入力側のidの値> GET相当
    値がない場合はundefined
    なし なし <数値>
    処理の返り値なしの場合0

みてくれの通り、内部仕様はいくらでもスタックできるので、こういう定義を書けば...

favoExtend/apidefs.ts
export const Login = new Definition(
  {
    path: '/login',
    method: 'POST',
    input: z.object({
      handle: z.string().regex(handleRule),
      passwd: z.string(),
    }),
    output: {
      token: '${#2}',
    },
  },
  [
    {
      keyRef: 'user/${#.handle}',
      functionName: 'jsonGet',
      output: z.string(),
      opts: {
        path: '$.passwd',
      },
    },
    {
      functionName: 'auth',
      input: {
        verifySrc: '${#.passwd}',
        verifyDist: '${#0}',
      },
    },
    {
      keyRef: 'token/${#.handle}',
      functionName: 'generateToken',
      output: z.string(),
    },
  ],
)

こういうAPIになります。

  • 外部仕様

    Path method 入力内容 出力内容
    /login POST Bodyへhandle=<string>,passwd=<string,任意> 処理成功時: {"token":"処理#2の実行結果"}
  • 内部仕様

    処理 key入力 処理内容 Value入力 処理のオプション 出力の内容
    #0 user/<入力側のhandleの値> JSON.GET相当 なし path: $.passwd <文字列>
    #1 なし(DB依存しない) auth
    ※ユーザ定義
    ユーザ定義
    verifySrc: <handle>
    verifyDist: <処理#0の結果>
    なし なし
    #2 token/<入力側のhandleの値> generateToken
    ※ユーザ定義
    なし なし <文字列>

しれっと出してしまいましたが、デフォルトで用意しているRedis起因の処理(GETSET,INCR相当)以外でも、自身で定義を記載して追加することが可能です。

処理追加はtypescript(使う場合はupstash/redis)の知識が少し必要です。

例えば、これはユーザ定義関数である generateToken の内容です

名前の通りランダムなtokenを作成する関数で、crypto.randomUUIDの結果をRedisへ書き込み & 返り値に指定しています。this.Redisupstash/redisを呼び出しています。

/**
 * Extend example: Generate token
 * @param key db key
 * @param input info for auth
 */
generateToken = async (key: string): Promise<string> => {
  try {
    // when you define function, recommend validation
    this.inputsValidation({ key })
    const token = crypto.randomUUID()
    const result: string | null = await this.Redis.set(key, token, {
      ex: 3600 * 24 * 7,
    })
    if (result !== 'OK') {
      throw new ExtendError({
        message: `Failed to SET value by redis client`,
        status: 500,
        name: 'Generate Token Failed',
      })
    }
    return token
  } catch (e: unknown) {
    if (e instanceof ExtendError) {
      throw e
    } else if (e instanceof Error) {
      throw new ExtendError({
        message: e.message,
        status: 500,
        name: e.name,
      })
    }
    throw new Error('Unexpected Error')
  }
}

ExtendErrorErrorのラッパーで、APIへ出力させることを意識しています。
インジェクション攻撃を避けるため、システム自体が5XX系エラーの場合は一定のエラー内容を出力し、Workersのログにのみ内容を残すようになっています。

このように、APIをDBへ実行する処理(メソッド)の組み合わせとして宣言的に定義できるシステムです。処理を数珠つなぎにしていくので、APIをパズル感覚で作成することができます。

マニュアルはこちら

Bookを発行しました。仕様をしっかり書いており骨太です。ぜひ参考にしてください!

https://zenn.dev/nkte8/books/favoextend-manual

開発経緯

以前、LambdaとReactでいいねボタンを作ったで、いいねのシステムを作りました。

https://zenn.dev/nkte8/articles/2024-01-02-r01

しかしこのシステムは正直、おまえの環境でしか使えないよね、というたぐいの出来でした。
ソースコードを公開していたのは、個人サイトを作っている方がいいねボタンを作る手助けにしたいな...という想いがあったのですが、そうはいかなかったみたいでした。

また、搭載Webサイトを運営していく中で、APIを実行しているアーキテクチャであるAWS Lambdaが低速であったことに課題意識がありました[1]

クラウドサービスの乗り換え

これに対し、Skyshareを作った際に使ったCloudflare WorkerUpstashが解決策になりました。

https://www.cloudflare.com/ja-jp/developer-platform/workers/

Cloudflare Workerは処理を実行するためにコンテナが開始されるLambdaと違い、非常に高速[2]
無料枠では10msのみですがfetchリクエストなどの待ち時間はこの処理時間に含まれません[3]

https://upstash.com

Upstashは同じく高速なサーバレスデータベースで、Redisを採用していることもあり非常に高速です。そして無料枠がめちゃくちゃ広い。Redisに関しては心配になるほど安いです。

Cloudflare Workerとの組み合わせも想定されており[4]、相性が非常によいです。

いいねの仕組みでは、大層な処理はしておらずこれらのクラウドサービスのほうが用途にマッチしていたため、AWSを離れ、これらでリファクタリングをすることにしました。

汎用性・拡張性

以前つくったいいねの仕組みでログインの機能を追加していましたが、それ以上のことをするために、新規でバックエンドのコードを書かなければならないという部分に負担を感じていました。
ある程度仕方ないとはいえ、毎回開発のたびに「AはBと処理が似ているからリファクタできる」「CはAによって不要になった」といったことは考えたくないと思います。

また、API自体の種類も増えていくことで制御処理を追加していく必要もあり、管理も大変です。

これらを解決するため、以下の機能を持つアーキテクチャが必要だと考えました。

  • API自体を直感的で宣言的に定義できること
  • APIによってDBへ行われる処理を、直感的で宣言的に定義できること
  • 新規APIの開発を簡単にできること

アーキテクチャを考える中で、これっていいねのシステムだけに限った話じゃないよね、という発想になり、汎用的なシステムとして開発の舵を切ることにしました。

仕様や仕組みについて

方法論についてはマニュアル側に記載しているので、ここでは仕様や仕組みについて記載します。

APIの型定義と処理の連結方法

開発の際に以下のような図を作成して整理していました。正直ソースコードのほうがわかりやすいです。

system

各型は以下のようになっています。API定義を作成する際は、RedisDefが配列として扱われる形です。

base/Definition.ts
import { z } from 'zod'

type JsonLiteral = boolean | number | string | undefined
type JsonObj = { [key: string]: JsonType }
type JsonType = JsonLiteral | JsonObj | JsonType[]
type KeyValue = { [key: string]: string }

type RelationType = { [key: string]: RelationType } | string

type ApiDef = {
    path: string
    method: string
    input?: z.ZodSchema<JsonType>
    query?: z.ZodSchema<JsonObj>
    output?: RelationType
}
type RedisDef = {
    keyRef: string
    multiKeysRef: string
    functionName: string
    input?: RelationType
    output?: z.ZodSchema<JsonType>
    ignoreFail: boolean
    ignoreOutput: boolean
    dependFunc?: number[]
    opts?: JsonObj
}
...

RelationTypeは関連付けを定義するための値です。Refという独自定義している値で、APIからのinputや処理の参照を行うことが出来ます。

例えば以下のような形で、値を後続の処理に渡していくことができます。

[
    {
        keyRef: 'A/${#.handle}',
        functionName: 'jsonGet',
        output: z.object({  // <-- 結果を { value: <string> } として後続に渡す
                    // 処理結果が { value: <string> }ではない場合は例外がスローされる
            value: z.string(),
        }),
    },
    {
        keyRef: 'B/${#0.value}', // <- 処理#0の結果の valueプロパティを参照
        functionName: 'get',
        output: z.string(), // <-- 結果を 文字列型 として後続に渡す
                        //  処理結果が 文字列型 ではない場合は例外がスローされる
    },
    {
        keyRef: 'C/${#1}', // <--- 処理#1の処理結果を参照
        functionName: 'set',
        input: {
            data: "${#0}" // <- 処理#0 の結果がそのまま代入され、
                      // { data: { value: <string> }} というinputになる
        }, // set処理は結果を出力しない(undefined)であるため、outputの定義は不要
    },
],

見て分かる通り、処理の入力と出力を、必ず、ZodSchema<JsonType>からRelationTypeで関連付ける仕組みになっています。また、処理結果はoutputの定義を満たしているかのVaridationが毎回行われるため、意図せぬデータの受け渡しが発生しないようにしています。

関数の拡張

最初の例で少し触れた通り、本プロダクトは拡張して利用する前提で作成しています。
処理は以下のように定義されており、これらの関数型に合うように設計して頂く必要があります。

関数型は目的に応じて柔軟に対応できるよう、多数用意しています。詳細はマニュアルを参照ください。

protected methods: {
    [x: string]:
        | {
                kind: 'methodOnly'
                function: (opts?: KeyValue) => Promise<JsonType>
            }
        | {
                kind: 'keyOnly'
                function: (key: string, opts?: KeyValue) => Promise<JsonType>
            }
        | {
                kind: 'string'
                function: (
                    key: string,
                    str: string,
                    opts?: KeyValue,
                ) => Promise<JsonType>
            }
        | {
                kind: 'json'
                function: <T extends JsonObj>(
                    key: string,
                    data: T,
                    opts?: KeyValue,
                ) => Promise<JsonType>
            }
        |
...
}

整理すると以下のような形です。ユーザはこのkindで示される関数の形でメソッドを書きます。

以下は利用可能な関数の一部です。

kind値 inputに使う値 備考
methodOnly opts(key-value) DBへ処理しない関数を実行する際に利用します。
関数への値はoptsを用いて渡します。
keyOnly key(string),opts DBの値を読み込む際に利用します。
読み込みオプションや、その他の値をoptsで渡します。
literal key(string),data(literal),opts DBへリテラル(文字列や数字など)を書き込む際に利用します。
Redisに書き込まれる際にリテラルはstring型として書き込まれます。
object key(string),data(Object型),opts DBへJsonを書き込む際に利用します。
リテラル自体は含まず、必ず{<文字列>: <リテラル/配列/Object>}の形態をしています。
戻り値について

戻り値はリテラル(string/number/boolean/undefined)、配列、Object(key-value)ですべて固定しています。

Outputの型を検証するのは、拡張性をもたせようとすると、どうしても定義の時点では型の判別をできないためです。kindを型ごとに増やすことになりますし、関数の実行処理自体をシステムに組み込んでいるため、APIや関数を定義する中でなんの助けにもなりません。

ならば最終的な処理結果が型を満たしていたら正とする、というのが理にかなっていたためこのような仕組みになりました。APIのinputを含み、すべてのZodSchema<JsonType>型は処理の前に型の検証が行われます。これによりDBへの書き込み内容が利用者の想定になるように、データを保護しています。

書いたメソッドはExtenderクラスを継承する際に追加処理として記載します。
addMethodメソッドを使って追加処理を行ってください。

favoExtend/index.ts
export class FavoExtend extends Extender {
    constructor(env: {
        UPSTASH_REDIS_REST_URL: string
        UPSTASH_REDIS_REST_TOKEN: string
    }) {
        super(env, [
            defs.GetFavo,
            defs.GetUser,
            defs.PostFavoWithAuth,
            defs.PostUserEdit,
            defs.Login,
        ])
        // Register your extend functions
        this.addMethod({
            auth: {
                kind: 'methodOnly',
                function: this.auth,
            },
            generateToken: {
                kind: 'keyOnly',
                function: this.generateToken,
            },
        })
    }
...
}

できること

プロダクト名こそfavoExtendで、いいねのシステムを拡張する仕組み(ユーザ登録や、ユーザごとのいいね数の取得機能)を例としていますが、RedisとAPIを接続する、いわゆるミドルウェアな立ち位置のプロダクトであるので、できることの幅は広いです。例を挙げておきます。

ユーザログインプラットフォーム

favoExtendの例として追加しています。/favo/user/loginエンドポイント全てに絡んでいて、/loginにより作成したトークンを用いて他のキーへデータを書き込めるようになっています。

/loginのコードは#どういうもの?に書いた通りです。/favoへのPost処理はログインありの場合とログインなしの場合で処理が分岐するように記載しています。

favoExtend/apidefs.ts
export const PostFavoWithAuth = new Definition(
    {
        path: '/favo',
        method: 'POST',
        input: z.object({
            id: z.string().regex(idRule),
            handle: z.string().regex(handleRule).optional(),
            token: z.string().optional(),
        }),
        output: {
            count: '${#3}',
            user: '${#2}',
        },
    },
    [
        {
            keyRef: 'token/${#.handle}',
            functionName: 'get',
            output: z.string(),
            ignoreFail: true,
        },
        {
            functionName: 'auth',
            opts: {
                verifySrc: '${#.token}',
                verifyDist: '${#0}',
            },
            ignoreFail: true,
        },
        {
            keyRef: 'user/${#.handle}/${#.id}',
            functionName: 'incrby',
            output: z.number(),
            ignoreFail: true,
            dependFunc: [0, 1],
        },
        {
            keyRef: 'favo/${#.id}',
            functionName: 'incrby',
            output: z.number(),
        },
    ],
)

掲示板(BBS)のバックエンドの作成

現代風に作るのであればCGIではなく、こういったDBとReactなどのSPA作成ツールで作るべきでしょう。Redisに登録するデータ構造は次のように設計します。

Redis上に置くデータの設計の例です。

key value
post/{postId}/{handle} post: <string: ポスト本文>
createdAt: <number: ポスト日時のUnixタイム>
attached: <string: 添付画像のURL(画像自体をRedisに置くのは非推奨)>
user/{handle} passwd: <string: パスワード文字列>
createdAt: <number: ユーザの作成時期>
url: <string: 自身のURL, bbsってなぜか自分にURL設定できたよね>

データの設計をしたら、後はAPIパズルを解くだけです。

例えば、全投稿の取得APIは以下のように書けます。

example/apidefs.ts
export const GetPosts = new Definition(
  {
    path: '/posts',
    method: 'GET',
    output: '${#2}', // <--- 戻り値はそのまま返却
  },
  [
    {
      keyRef: 'post/*',
      functionName: 'scan',
      output: z.string().array(),
    },
    {
      functionName: 'typeGrep',
      output: z.string().array(),
      opts: {
        keys: '${#0}',
        type: 'json',
      },
    },
    {
      functionName: 'jsonMget',
      multiKeysRef: '${#1}',
      output: z
        .object({
          post: z.string(),
          createdAt: z.number(),
          attached: z.string(),
        })
        .array(),
    },
  ],
)

この定義をindex.tsで読み込ませAPIをビルド、https://example.com/postsへGETリクエストを送れば、[{"post":"本文","createdAt":12345678,"atacched":"https://..."},{...},{...}]といった配列を得ることが可能です。

スコアランキングの作成

RedisのSortedSetは、スコア値によって自動的にDBで並び替えが行われるデータリソースです。
favoExtendでもこのデータタイプは使えるようにしています。

下記は、ユーザのいいね数をランキング登録する例です。rank/favoにSortedSetを設定しています。スコアの値は処理#0でデータベースの他の値から作成しています。

戻り値は処理#2により、降順でスコアを並べたランキングの値を設定しています。

test/mockdefs.ts
export const TestAddRanking = new Definition(
    {
        path: '/addRank',
        method: 'POST',
        input: z.object({
            handle: z.string(),
        }),
        output: { rank: '${#2}' },
    },
    [
        {
            keyRef: 'user/${#.handle}/*',
            functionName: 'incrSum',
            output: z.number().default(0),
        },
        {
            keyRef: 'rank/favo',
            functionName: 'zadd',
            input: {
                score: '${#0}',
                member: '${#.handle}',
            },
        },
        {
            keyRef: 'rank/favo',
            functionName: 'zrevrank',
            input: '${#.handle}',
            output: z.number(),
        },
    ],
)

今後の課題

APIを宣言的に定義・追加したり、処理の書き足しは容易になった一方、今度はDBの状態がわかりずらくなってしまいました。DBのデータについても宣言的に定義されたら便利な気がします。

ところでORM(オブジェクト関係マッピング)というものが存在するようでした。DBの内容を言語によって管理できるようにする仕組みの総称のようです。

TypescirptとRedisを紐づける仕組みも、すでにRedisが公式に出しているみたいでした。

https://redis.io/blog/introducing-redis-om-for-node-js/

https://github.com/redis/redis-om-node

これを使ったら、API-Typescirptの紐づけだけではなく、Redisのデータもかなりちゃんと管理出来そうな気がしています。といってもどのように取り込んでいくのか考える必要がありますが...

さいごに

すごく頑張って作ったので、個人サイト運営者で、ちょっと発展したことをやりたい!けど一から作るのはつらい...という方、ぜひ使ってみてね。もちろんコントリビュートもウェルカムです!

リポジトリ(再載)

https://github.com/nkte8/favoExtend/tree/main

マニュアル(再載)

https://zenn.dev/nkte8/books/favoextend-manual

脚注
  1. Lambda自体は高速なのですが、Lambdaでは関数を実行するにあたってスタートアップ処理が必要になっており、この仕組み自体がいいねボタンとかみ合っていませんでした。 ↩︎

  2. 0ms cold startsという仕組みをとっているようです。非常にわかりやすい解説(2021/12/28更新): Cloudflare Workersの0ms cold startsのしくみ - すな.dev ↩︎

  3. 非常にわかりやすい説明(2023/07/25更新): Cloudflare Workers を使う前に知っておきたい注意点 ↩︎

  4. Upstashからライブラリが出ており、Cloudflare公式もそれを認知しています。Cloudflare KVが競合かと思っていましたが、仲良しだったりするのかしら...? ↩︎

Discussion