Misskeyをフォークしてリファクタリングしまくっている
追記(2023/10/01)
モチベーション低下により現在リファクタリング作業を中断しています。
backendでのデータベースの使い方を改善しようとしていたのですが、関連データベースなのに関連していなかったり、非正規化されているところがあったり、EAVが行われていたり、UNIQUE制約の抜けがあったりと、全体的に厳しいものがありました。データベースの定義そのものを改善しなければならないのですが、そのためには相当破壊的な変更をする必要があり、正直そこまでする元気がないのです。
はじめに
日本発の分散型SNS用のOSSとして、Misskeyというものが注目を浴びています。今ほどの注目を浴びるようになったのはここ最近のことですが、Misskey自体は9年ほどの歴史を持つソフトウェアで、私もMisskeyを使って構築されたSNSを5年近く利用しています。また、最近はその開発にも少しだけ参加してみています。
しかし、本家Misskeyとは異なる方針で開発してみたいと思う出来事があったため、本家に追従しない形でフォークすることにしました(v13.14.2/b6790a4
ベース)。この記事では、Misskeyで使われている技術や私がフォークするに至った経緯や私のフォークの進捗などをまとめます。
Misskeyについて
MisskeyはSNSを構築するためのサーバソフトウェアです。ActivityPubというオープン標準を実装しているため、Mastodonなどの同じくActivityPubを実装したソフトウェアによって構築されたSNSと連合することができます。その特徴から、Misskeyは分散型SNSの実装の一つとして数えられます。
Misskeyは次のようなコンポーネントによって構成されています。
- backend
- TypeScriptで書かれ、Node.jsで動く
- NestJSのDIを使って書かれている
- データベースとしてPostgreSQLをTypeORMから扱う
- インメモリデータベースとしてRedisをioredisから扱う
- BullMQを使ってジョブキューが実装されている
- frontend
- TypeScriptで書かれ、Vue.jsを使っている
- misskey-js
- backendが提供するAPIをfrontendが使うためのSDK
- TypeScriptで書かれている
- mfm-js
- MFMと呼ばれるマークアップ言語をパースするもの
- メンションやハッシュタグなどはMFMの機能の一つ
- TypeScriptで書かれている
- sw
- プッシュ通知用のServiceWorker
- TypeScriptで書かれている
- slacc
- backend内部で使われるライブラリ
- Rustで書かれていて、NAPI-RSを使ってNode.jsから呼び出せるようになっている
- tsdが出力されるのでTypeScriptからも扱いやすい
基本的にどれもTypeScriptで書かれています。そのため協調した開発が可能……なはずでしたが、いろいろあってなかなか大変なことになっているところがあります。
不十分なAPIのリクエスト/レスポンスの型定義
残念なことにmisskey-jsの型定義にはTODOとしてのRecord<string, any>
が多く、またoutdatedな型定義も多いことから、frontendの開発に深刻な支障をきたしています。frontend側では非常に多くの型エラーが出力されていて、その無視や握り潰しなどでエラーが見過ごされている可能性があります。
現在のmisskey-jsの型定義は人の手によって更新されていますが、流石にそれでは無理があるということでしょう。なんらかの手段によって、backendの提供するAPIのリクエスト/レスポンスの型定義とmisskey-jsの型定義を容易に同期できるようにしなければなりません。
backendは/api.json
というURLでOpenAPI Specも出力します(OpenAPIServerService.ts
)。OpenAPI SpecからSDKを生成するツールを使えば、misskey-jsを置き換えることができるかもしれません。また、OpenAPI SpecからTypeScriptの型定義だけを生成するツールというものもあるので、misskey-js全体の置き換えではなく、型定義の部分だけをうまく置き換えるということもできるかもしれません。
しかしながら、backendの出力するそれはOpenAPI Specとしてinvalidであり、またそもそもそちらにも未定義や誤りが含まれます。
backendが出力するOpenAPI Specは、backendがAjvによるバリデーションに使っているスキーマを流用したものです。Ajvでバリデーションに使うスキーマは(バージョンにもよるものの)JSON Schemaに準拠していて、OpenAPI Specは(バージョンにもよるものの)JSON Schemaに準拠しています。そのため、流用も不可能ではないはずなのです。
AjvはTypeScriptからは扱いにくいものです。Ajvによるバリデーションに通った値に対する自動的な型の定義は非常に限定的にしか行われません。ではbackendでどう対処しているのかと言うと、「バリデーション用スキーマから型定義を生成するもの」を使って生成された型定義を型アサーション(as
)によってバリデーションに通った値と紐付けているのです(endpoint-base.ts
)。
この型定義生成器は型レベルプログラミングの産物であり、保守性が低いです。型定義が不適切であるこの問題がなかなか解決されない原因のひとつとして、この部分の保守性の低さが挙げられるでしょう。またこの部分の実装には問題があり、不正な入力(スキーマ)に対しany
を返すようになっています。そのためas any
となり、型チェックが行われないでいる場面があります。そもそも型レベルプログラミングには限界があり、複雑すぎることはできないか、しづらいです。
問題があったり複雑すぎたりするスキーマがany
とされるために型エラーが出力されない状況が、巡り巡ってinvalidなOpenAPI Specを放置していることに繋がっているのです。
また、APIのスキーマとしては「リクエストのもの」と「レスポンスのもの」の2種類があります。このうちAjvを通してバリデーションをしているのは前者だけです。Ajvは不正なフォーマットが入力されたときに実行時エラーを出力するため、前者に関しては比較的きちんと定義されています。問題は後者です。後者はAjvを通していません。そして、Ajvの指定するフォーマットすらも満たしていません。これもまたOpenAPI Specをinvalidにさせている原因のひとつです。
ここまでをまとめると、
- frontendの型エラーとそれの握り潰しによるバグの隠蔽をなくすにはmisskey-jsのTODO型をなくす必要がある
- しかしmisskey-jsを、現在のやり方のままメンテナンスするのは骨が折れる
- backendのバリデーション用定義には問題があり、それを流用したOpenAPI Spec自体にも問題がある
- そのためmisskey-jsをOpenAPI Specから自動生成するようなこともできない
- backendのバリデーション用定義はbackend内での型定義生成にも使われているが、型定義生成器などの実装の問題により、生成された型定義は不適切なものになっている
バリデーション用に定義されたスキーマは、本来の用途であるバリデーションと、backendでの型定義と、OpenAPI Specの生成の3つの場面で使われています。3兎を追うにはやり方がよくないと考えられます。
この問題へのMisskey本家の方針
この問題への対処としてMisskey本家は、型定義生成器をリファクタリングすることで適切な型定義を提供できるようにしつつ、バリデーション用定義をbackendとmisskey-jsで共有することによって同期できるようにしようとしています。
難しい問題なため一概には言えませんが、保守性の低い型レベルプログラミングをするという選択をしたことなどから、私個人としては快くは思っていません。しかし私にはその方針に口を出すことはできません。議論を経ずに作られたそのPRにはすでにある程度の進捗がありますし、私は以前この問題についてコラボレーターにだいぶ強い言葉で意見を否定されたことがあって、少なくともこの問題に関してはMisskeyの開発陣と関わりたくありません。
私のMisskeyフォークでの方針
Misskey本家に物申すことはしたくありませんが、そうは言ってもこの問題は解決したいものです。そこで私は、私の考える方針が実際のところどうなのか確かめるために、フォークして実際に試してみることにしました。これが私がMisskeyをフォークした具体的な経緯です。
私のフォークではこの問題に、Ajvをやめることで対処しようとしました。そもそものAjvをやめ、ZodのようなTypeScript-firstなバリデーションライブラリを使うのです。バリデーション用定義のフォーマットはまったく異なる形になってしまいますが、バリデーション用定義から型定義を手に入れるのはとても簡単です。型レベルプログラミングが不要という意味で、こちらの方が優れていると考えます。バリデーション用定義すべてを書き直す必要はありますが、そもそも誤りや未定義が多いのでいずれにせよすべてに目を通して書き換える必要があります。
また、世の中にはZodのバリデーション用定義からOpenAPI Specを生成するライブラリもあります。OpenAPI Specさえきちんと生成できたら、そこからmisskey-js用の型定義、もしくはmisskey-jsを置き換えるSDKを自動で生成できるでしょう。もしSDK自体の自動生成をするとなるとmisskey-jsを使った既存のコードすべてを書き換える必要があって、なかなか大変ではありますが……。
また、backendとmisskey-jsがOpenAPI Specを介して型の整合性を持つという方法は、スキーマ自体を共有するというMisskey本家の方法よりも疎結合であると考えられます。もし今後backendの実装言語を変えたくなったとき、その作業の難易度を抑えられるでしょう。とはいえ少なくとも現時点ではbackendの実装言語を変えることは私のフォークでは考えていませんが。
なお、AjvからZodへの移行は非常に大規模なものになるため、Misskey本家の今後の更新には追従しない形でのフォークになります。実際、フォーク後にMisskey本家では、すべてのファイルにライセンス情報を追加したり、すべてのエンティティにMi
というprefixを追加したりしていて、とてもconflictを解消できるような雰囲気ではありません。
私のフォークの進捗
341あるAPIエンドポイントすべてでのZod移行が完了しており、backendのAjvへの依存はすでになくなっています(5a67876
)。ひたすら手を動かせばいいだけの作業だったので楽でした。
また、zod2specというライブラリを作って、ZodスキーマをもとにOpenAPI Specを出力するようにもしました(102ebc9
)。ZodからOpenAPI Specを作るライブラリは自作するまでもなくすでにいくつかあるのですが、どれも私の求める機能はありませんでした。
例えば@asteasolutions/zod-to-openapiは完全なOpenAPI Specを出力するもので、その処理に介入することはできません。これまでbackendでは、完全手動生成ゆえの柔軟さを活用してOpenAPI Specを作っていましたが、それをうまく置き換えることはできそうになかったのです。
また@anatine/zod-openapiは部分的な出力に対応していたため私のフォークでも途中までは使っていましたが、$ref
を使って事前に定義しておいたスキーマを参照する仕組みが使いづらいものしかないようだったため、やめました。
私のzod2specのgenerateOpenApiSpec
関数では、入力されたZodのバリデーション用定義を再帰的に読み込んでいき、その過程であらかじめcomponents
として入力されていたと定義と同じ定義が出てきたときには自動的に$ref
へと置き換えるような処理をしています。
例えば、次に示すのは通報のスキーマですが、reporter
(通報者)やtargetUser
(通報対象)やassignee
(担当者)はどれもUserSchema
です。
export const AbuseUserReportSchema = z.object({
id: z.string(),
createdAt: z.string().datetime(),
comment: z.string(),
resolved: z.boolean(),
reporter: UserSchema,
targetUser: UserSchema,
assignee: UserSchema.nullable().optional(),
});
UserSchema
自体はAbuseUserReportSchema
同様に定義されたごく普通のZodのバリデーション用定義です。
これをgenerateOpenApiSpec
関数に次のように入力すると、
const components = [
{ key: "User", schema: UserSchema },
];
generateOpenApiSpec(components)(AbuseUserReportSchema);
最終的に出力されるデータでは次のようになります。
{
"type": "object",
"properties": {
"id": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" },
"comment": { "type": "string" },
"resolved": { "type": "boolean" },
"reporter": { "$ref": "#/components/schemas/User" },
"targetUser": { "$ref": "#/components/schemas/User" },
"assignee": { "$ref": "#/components/schemas/User", "nullable": true },
},
}
これができるライブラリがなさそうだったので自作することにしました。
これらの変更により、OpenAPI Specがinvalidである問題が解決しました。
そして、openapi-typescriptというツールを使ってOpenAPI SpecからTypeScriptの型定義を生成しました。misskey-js内部ではそれが使われるようになり、frontendでよりしっかりとした型の恩恵を受けられるようになりました(06f0702
)。
私のフォークの今後
仕方なくとはいえMisskey本家に追従しない形でフォークすることになったので、今後は私がやりたいことをやろうと思っています。ただ、AjvからZodへの移行作業をしながらソースコードを眺めたのですが、Misskeyのbackendには多くの問題があるように見受けられました。また、frontendも少し覗いてみたのですが、型エラーやその握り潰しは依然として大量にあります。これらが残った状況で新機能を実装することはしたくないしすべきでないと思っているので、まずはこれらを解消しようとしています。
Misskeyのbackendの問題点の例
TypeORMのエンティティ初期化処理が雑
MisskeyではTypeORMからPostgreSQLを使っていますが、TypeORMの使い方によくないところがありました。
TypeORMはCode-firstなORMで、EntityはTypeScriptのdecoratorを使ったclassとして定義されます。Misskeyではそのclassにconstructorを独自に定義して、初期化時に値を設定できるようにしていることがあります。しかしこの初期化処理は非常にずさんです。例としてNote.ts
におけるNote
というEntityを簡略化したものを示します。
export class Note {
@PrimaryColumn(id())
public id: string;
@Column('text', { nullable: true })
public text: string | null;
@ManyToOne(type => User, { onDelete: 'CASCADE' })
@JoinColumn()
public user: User | null;
constructor(data: Partial<Note>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}
if (data == null) return;
はTypeORM側のよくわからない挙動に対処するためのものですので、デッドコードではありません。とはいえ引数のところでdata?: Partial<Note>
とでもしておくべきでしょう。その上で、static create(data: Partial<Note>) { return new Note(data); }
のようなメソッドを作っておいて、そちらから初期化する癖をつけるとかした方がよかったかもしれません。
まず、constructor
はPartial<Note>
を受け取ります。これは、プロパティが未定義であることを許容したNote
型のことです。Misskeyのbackendではtsconfig.json
でexactOptionalPropertyTypes
が未定義なため、プロパティがundefined
であることも許容されています。Note
型は自分自身のことですから、ここでは{ id: string; text: string | null; user: User | null }
となります。
それをObject.entries
でkeyとvalueのペアの配列として手に入れています。(Object.entries
はそもそもの型定義がよろしくないのですが、それについては深くは触れないでおきます。)
問題は、(this as any)[k] = v;
の部分です。this[k]
は常に存在しますが、TypeScriptには怒られる書き方ですのでas any
をしているようです。そもそも怒られるような書き方はすべきではないと思うのですが。……そしてthis[k]
に対しv
を入れています。しかしここでv
は、(tsconfig.json
が十分に厳しくない開発環境で)Partial
したために本来の型とundefined
とのunionになっています。undefined
を、それが認められていない変数へ代入することが許されてしまっています。実際にnew Note()
する場面ではundefined
が混入しないように注意を払われていますが、型エラーは出ません。注意を払わなければならないのは苦痛です。私はTypeORMについて詳しくないためundefined
を実際に代入したとき具体的に何が起きるのかはまだ理解できていないのですが、少なくとも実行時エラーが起きることがあることは確認済みです。
これは、
-
as any
で握り潰すべきではない - そもそも
Partial
すべきでない - より厳しい
tsconfig.json
を設定すべき
などの複合的な問題ですが……TypeORMでのエンティティ定義は元々難しいものですので、もう少ししっかりと書くべきだっただろうと思います。
tsconfig.json
が緩い
ROADMAP.md
ではbackendの型エラーがゼロであることが書かれていますが、これは現時点のゆるいtsconfig.json
に照らしたときのものであり、より適切な設定で型チェックを行えば、そして型エラーの握り潰しをなくせば、たくさんの型エラーが出力されるでしょう。@tsconfig/strictest
を使った型チェックでは1000個以上の型エラーが出力されました。@tsconfig/strictest
は少し厳しすぎるかもしれませんが、せめて前述のexactOptionalPropertyTypes
くらいは有効化しておきたいものです。
データベースの型とTypeScriptの型に相違がある
TypeORMはTypeScriptから扱いにくいもので、@Column()
デコレータを使用してEntityにカラムを定義しても最終的に型定義を書くのは人間です。このとき、データベース上のvarchar
をenum
のように特定の文字列のunionとして定義している部分があったり、nullable: true
ではないのにnull
とのunionになっていたりします。またjsonb
はバリデーションされずに使われていて、型定義すらもされていないことがあります。
awaitAll()
の不適切な使用
awaitAll()
というユーティリティ関数が使われている箇所があります。これはRecord<K, V | Promise<V>>
をPromise.all()
を使っていい感じにPromise<Record<K, V>>
にするようなものです。
しかしわざわざこれを使うまでもない場面で使われていたり、これを使っているというのにプロパティそれぞれでawait
をしている(Promise.all()
の利点を活かしていない)場面があります。
awaitAll()
はデータベースへの問い合わせPromiseの解決を待ちつつ、問い合わせた結果をプロパティに含むオブジェクトを構築する部分でよく使われています。
// シンプルにした例
return await awaitAll({
id: clip.id,
name: clip.name,
description: clip.description,
isFavorited: meId
? this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) // 問い合わせPromise
: undefined,
});
しかしこれは、「問い合わせること」と「問い合わせた結果からオブジェクトを構築すること」が分離されていないため、問い合わせる部分を最適化しようにもしづらいというそもそもの問題点もあります。問い合わせは問い合わせで別のところでまとまってもらっていた方が理解しやすいです。
Promiseが増えると——
const [a, b] = await Promise.all([promiseA, promiseB]);
——というような書き方では可読性に問題が生じてしまう、というのは理解できますが、だからといってawaitAll()
はよくなかったでしょう。現に適切に使われていませんし。
その都度データベースへ問い合わせている
前述のawaitAll()
は主にデータベースへの問い合わせPromiseの解決を待つときに使われています。データベースへ複数のクエリを一度に投げているのです。SELECT
に相当する操作でJOIN
を適切に使用せず、その都度クエリを発行している部分があるのです。これがどのような理由によって行われているのかは不明ですが、何度も問い合わせられるデータベースもたまったものではないでしょう。これをなくすことでパフォーマンスの向上が見込めます。
私のフォークのWIP
データベース関連のいくつもの問題を一挙に解決する手段として、私のフォークではPrismaへの移行を進めています。独自の記法を用いて書いたスキーマから生成された型定義を使うPrismaは型安全ですし、PrismaではJOIN
しやすいAPI(include
)が提供されています。JOIN
したときの返り値にはもちろんきちんとした型が定義がされます。
しかしPrismaへの移行は一筋縄ではいきません。Misskeyのbackendでは、TypeORMのエンティティ型を引数として渡される関数などがあるのです。これらの引数により適した型を定義しておかなければ、Prismaを使ってデータベースから得た結果を渡せません。そこで私は、TypeORMのエンティティ型とPrismaの(スキーマから生成された)型の共通部分のみを取得する型を作りました。前述の引数の型定義をひとまずこれの結果に置き換え、型エラーが報告されたらそれを解決する……という作業を行うことで関数をTypeORMとPrismaの両方に対応させて、段階的にTypeORMへの依存を減らしていこうという目論見です。
記事執筆時点の状況としては、backendの中でもchartと呼ばれる難解な領域を除くほぼすべての場所でPrismaへの移行が完了しています(1db23ac
)。TypeORMの型を使用している箇所ももうありません(a7642af
)。
現在、クエリの最適化やそのための他の部分のリファクタリング、Ajvから移行しただけでまだ厳密ではないAPIのZod定義をよりよくする作業をしています。
おわりに
この記事では、Misskey内部の技術的な話と私のフォークについて軽くまとめました。
何をするにもまずはリファクタリング、というような状況で、新機能の実装などの楽しいことがまったくできておらず、つらいです。後々リファクタリングすることになるfrontendのためにもまずはbackendをしっかりと整備しよう、と思って頑張っています。
現時点の私のフォークは、握り潰されていた型エラーが出力されるようになっていること、テストに通っていない部分があること、一時的にパフォーマンスの悪い書き方をしている部分があること、(Dockerイメージのビルドを試せる環境がないために)おそらくビルドできない状況であることなどから、実際に使えるような状態ではまだありません。現時点では間違いなくMisskey本家の方が使うには適していますので、Misskeyに興味がある場合は本家をどうぞ。
補足:Rust化について
これについて触れられそうな文脈がなかったため補足という形でここに書きます。
Misskey本家では、パフォーマンスの問題を理由に「Node.jsをやめてRustなどにしたい」という議論がされています。
が、少なくとも私のフォークではNode.jsをやめる予定はありません。理由としてはいくつかありますが、最大のものは、「そもそもパフォーマンスが悪いのはNode.jsのせいではないのではないか」というものです。
この記事でも触れた通り、backendのデータベースの使い方はよくはありません。この記事では触れなかったデータベース関連の問題もあります。(適切にrelationが設定されていない、適切にuniqueが設定されていない、jsonbがたくさん使われている、EAVしている、など。)これらの問題にはパフォーマンスに関係するものもあります。「たとえRust化してもデータベースの使い方を見直さない限り、大してパフォーマンスはよくならないのではないか」と思ってしまうほどには現在のデータベースの使い方はよくないものです。私のフォークでは実際にNode.jsのままリファクタリングをして、パフォーマンスが悪いのはNode.jsのせいではないことを検証しようとしています。
あと普通に現在の難解なコードをそのままRustに置き換えるようなことはとてもできそうにないので、どっちみちリファクタリングは必要だと思います。本家はどうやって移行するつもりなんでしょう? Issueを立てたり、この計画を理由とした意見をPRにしたりするくらいなので実際の移行手順も考えてありそうですが……。
Discussion