ホームページをSveltekitに移行しようとして失敗した(ている)話 - Node.jsにおけるORMの選定
calendar_today
2023-09-25
insights
views: 298
thumbnail images

発端

Livewire 3.xがリリースされて、社内システムをアップグレードしたけど特に素晴らしい変更などがなく(元々素晴らしいので)、新しい知識を入れたいと思い、今回ホームページをSveltekitにしようとしたのがきっかけ。

技術選定

今回はユーザビリティ向上という目標を立てて、以下の条件で絞ってみた。

  • preload(prefetch)ができる
  • SSR
  • JSフレームワーク
  • 小さなプロジェクトを素早く作れる
  • 有名フレームワークの派生でないこと

フレームワーク

フレームワークを選定するに当たって、Next.js、Nuxt.js、Sveltekitの3つが思い当たった。
ただNext.jsは素早く作るという点においてReactが適していないと思ったので今回はパス。
Nuxt.jsとSveltekitで迷ったが、どうせなら早く、軽く、Livewireと同じように仮想DOMを使用しないということでSveltekitを選択。もちろんTypeScriptで作成。

認証ライブラリ

認証機能がフレームワークに付属していなければライブラリを使用するものだと思っていたので探してみたところ、以下を発見。

  • Auth.js(旧: NextAuth)
  • Lucia
  • SK-Auth
    ...

Auth.jsは多機能でコミュニティが活発であったものの、NoSQLのUUIDを前提とした作りなのか、JWTの関係かどうかわからないが、プライマリキーをintで作成することができなかったのでパス。
LuciaはAuth.jsよりシンプルで導入が簡単だったものの、同様の理由でパス。
SK-Authなどの有志の作成しているものはNextAuthをベースにしているものが多く、同様の理由でパスした。

結局認証部分を自分で作ることになったが、やり方が分かったらめちゃくちゃ簡単だったので、スクラッチで作成した。JWTとSveltekitにあるSession機能でセキュリティ的にライブラリと同等のものが作成できた。

ORM

ORMが問題だった。
ORMについて、まずは最近勢いのあるPrismaを選択した。これが問題だった。

Prismaレビュー

良い点

まずモデルの作成に関しては、既存のDBをもとに生成してくれるので、かなり時間の節約になった。
あとTypeScriptの型が厳密に定義されているのも良い(逆にそれでエラーが出て解消するために複雑な型を定義しなければならないという苦労もあるが...)

問題はここから。

悪い点

低レベル(レベル的な話)のクエリビルダしか用意されておらず、例えばデータ取得の際はすべてをひとつのメソッドにぶち込む形式。
Object内でforeachは使用できず、mapで作成してもネストが深すぎて、ひと目では訳の分からない関数を定義することになる。
だから、死ぬほど見通しの悪いプログラムが出来上がるか、オレオレObject生成機を定義してそれを使う(見通しが悪くなる)を使うかの2択になる。
以下はインデントを誇張したものだが、誇張出来るのがそもそも問題であるといえる。

const data = await db.posts.findMany({
    where: {
        OR: [
            {
                AND: [
                    { title: { contains: 'Laravel' } },
                    { title: { contains: 'Livewire' } }
                ]
            },
            {
                AND: [
                    { content: { contains: 'Laravel' } },
                    { content: { contains: 'Livewire' } }
                ]
            },
            {
                AND: [
                    { createdBy: { name: { contains: 'Laravel' } } },
                    { createdBy: { name: { contains: 'Livewire' } } }
                ]
            },
            {
                tags: {
                    some: {
                        tag: {
                            AND: [
                                { name: { contains: 'Laravel' } },
                                { name: { contains: 'Livewire' } } // 特にbelongs to manyがひどすぎる
                            ]
                        }
                    }
                }
            }
        ]
    }
})

ちなみに上記をLaravel Eloquentで作成すると以下になる。

$query = Post::query()
$query->orWhere(function ($q) {
    $q->where('title', 'LIKE', "%Laravel%");
    $q->where('title', 'LIKE', "%Livewire%");
});
$query->orWhere(function ($q) {
    $q->where('content', 'LIKE', "%Laravel%");
    $q->where('content', 'LIKE', "%Livewire%");
});
$query->orWhereHas('createdBy', function ($q) {
    $q->where('name', 'LIKE', "%Laravel%");
    $q->where('name', 'LIKE', "%Livewire%");
});
$query->orWhereHas('tags', function ($q) {
    $q->where('name', 'LIKE', "%Laravel%");
    $q->where('name', 'LIKE', "%Livewire%");
});
$query->with('tags', 'createdBy');
$data = $query->get();

ひとつのメソッドに色々ぶち込む形式は地獄絵図にしかならないことはCakePHP2で痛いほど理解しているので、却下。

Objection.jsレビュー

良い点

こちらはかなりEloquentに近い記述ができるORMで、PHPerの自分にも扱いやすいものだと思った。
TypeScriptでも、型がガチガチでなくサジェスト程度に効くレベルでLaravelと同等に扱えた。
Knex.jsをベースに作られているらしく、ObjectionとKnexのドキュメントを横断しなければならないのは若干億劫だが、その分ドキュメントがしっかり作られているのが良い。

ちなみに先程のクエリをObjection.jsで書くと以下の通り。ほとんどEloquentと同じようにかける。

const query = Post.query()
query.orWhere(q => {
    q.where('posts.title', 'like', `%Laravel%`)
    q.where('posts.title', 'like', `%Laravel%`)
})
query.orWhere(q => {
    q.where('posts.content', 'like', `%Laravel%`)
    q.where('posts.content', 'like', `%Laravel%`)
})
query.orWhere(q => {
    q.where('createdBy.name', 'like', `%Laravel%`)
    q.where('createdBy.name', 'like', `%Laravel%`)
})
query.orWhere(q => {
    q.where('tags.name', 'like', `%Laravel%`)
    q.where('tags.name', 'like', `%Laravel%`)
})
query.withGraphFetched('[tags, createdBy]') // ここは少し奇妙
const data = await query

ただ、上記のクエリには制限がある。

悪い点

上記には注意点がある。withGraphFetched()は、limit()やページネーション用のpage()が使用できないということ。
これはつまり、オフセットやlimitで制限をかけたいのであれば、自力でデータをフェッチするロジックを作成するしかないということ。
流石にこの致命的な欠点はGithubでもこの問題がかなり前からissueに上がっているらしいが、複数のDBエンジンに対応させる重い仕事のためになかなか対応されていないよう。ちなみにもう一つのwithGraphJoined()も多少注意点は異なるが、制限などに関しては同様。

自力でデータを整形するコードを書く手間を省きたいがためのORMなのに、自力で書かなければならないのはおかしいと思うのは、Eloquentで慣れすぎた自分の甘えなんだろうか?

その他のORM

Sutando.jsというEloquentにかなり寄せたものも使用したが、結局Knex.jsベースであるため上記に対応できないらしい。(それに個人開発なので継続使用は難しそう)
TypeORMはひどいだの時間を返せだの散々な言われようだったり、SequelizeはPrismaと同じようなシンタックスなのであまり変わらなかったりと、ORMの選定が全く進んでいない。(現在進行形)

さいごに

今のところ、Objection.jsで開発を続けるか、何かしら新しいORMを見つけるか、"こういうものだ"と思ってPrismaに戻るかは検討中。
ここまでAuthにしろORMにしろ、ライブラリを試しまくってかなり疲弊しきっている。(ちなみにNuxt.jsも途中まで組んでいた)
最終的に何かしらで決着はつけたいが、少なくともNode.jsの仕事を受けるようになったときに今回の知見が生きてくると思うので、20分怠けるために50時間を費やす自分としてはもう少し粘ってみたいと思う。

calendar_today
2023-09-25
insights
views: 298