サーバ側の書込み保護のファイルキャッシュをGoで作ってみた
ファイルシステムの状態を、ファイルと一対一の関係を持つデータベースと同期させることは、なかなか難しい作業である。この記事では、この問題に対する解決策をどのように実装したかを説明する。
この記事に掲載されているすべてのコードはGPL-3ライセンスとなっている。
はじめに
漫画など様々な絵のコレクションも保存、管理、閲覧できるためのフルスタックアプリケーションMangatsuをオープンソースの形で開発している。構成としては、Goで書かれているサーバ側(APIアクセスとユーザー管理を提供等)と、TypeScriptとNext.jsで書かれているクライアントの形になっている。この記事では、主にサーバーアプリケーションに焦点を当てる。
「Mangatsu」は「満月」と「漫画」の混ぜ言葉
Mangatsuは、ユーザーが指定したディレクトリパスをスキャンし、そこに置いている漫画、コミック、同人誌、およびその他のコレクション(以下はギャラリーと呼ぶ)のファイル名等を元にし、可能な限りの情報を解析してくれる。
ディレクトリ構造は2つの異なる型が可能となっている(freeform=自由構造、structured=体系立った構造):
📂 freeform
├── 📂 doujinshi
│ ├──── 📂 deeper-level
│ │ ├──── 📦 [Group (Artist)] 同人誌◯.cbz
│ │ └──── 📄 [Group (Artist)] 同人誌◯.json
│ ├──── 📦 (C99) [Group (Artist)] 漫画〇〇.zip
│ ├──── 📄 (C99) [Group (Artist)] 漫画〇〇.json
│ └──── 📦 (C88) [group (author, another author)] 単行本 [DL].zip # (JSON or TXT metafile inside)
├── 📂 art
│ ├──── 📂 [Artist] art collection
│ │ ├──── 🖼️ 0001.jpg
│ │ ├────...
│ │ └──── 🖼️ 0300.jpg
│ ├──── 📦 art collection XYZ.rar
│ └──── 📄 art collection XYZ.json
└── 📦 (C93) [group (artist)] 同人誌△ (Magical Girls).cbz
📂 structured
├── 📕 漫画1
│ ├── 📦 Volume 1.cbz
│ ├── 📦 Volume 2.cbz
│ ├── 📦 Volume 3.cbz
│ └── 📦 Volume 4.zip
├── 📘 漫画2
│ └── 📂 Vol. 1
│ ├── 🖼️ 0001.jpg
│ ├── ...
│ └── 🖼️ 0140.jpg
├── 📘 漫画3
│ └── 📂 Vol. 1
│ ├── 📦 Chapter 1.zip
│ ├── 📦 Chapter 2.zip
│ └── 📦 Chapter 3.rar
├── 📗 漫画4
│ ├── 📦 Chapter 1.zip
│ ├── ...
│ └── 📦 Chapter 30.rar
└── 📦 One Shot漫画.rar
例に示されているように、多くのギャラリーはzip/cbz、rar/cbr、7zipなどの形式で圧縮されていることが多い。同じファイルをリクエストごとに解凍して、かえってサーバーの負荷と待ち時間の増加を防ぐため、ユーザーがクライアントでギャラリーを開くたびにファイルを解凍するのは望ましくでない。
クライアント側で結果をキャッシュ※することも一つの方法ではあるが、それは信頼性に欠け、悪意のあるユーザーによる乱用を防ぐことができず、特にMangatsuのようにキャッシュデータが非常に大きくなるアプリケーションでは、クライアント(ブラウザやスマートフォンアプリなど)にとって実用的ではないだと思う。
※実際にMangatsuでは両方、クライアントキャッシュとサーバキャッシュも併用している。クライアントの方は、ギャラリーごとにではなく、ページごとで管理されていて、ほぼ完全にブラウザに任せている形となっている。
サーバー側ファイルキャッシュの実装
... こちらの方は割と簡単にできる。
まず、キャッシュディレクトリを初期化する方法:
次に、キャッシュから読み取るおよびキャッシュに書き込む関数の実装です。キャッシュに書き込む際には、ギャラリーを解凍する前に2つのチェックを行う:
- キャッシュディレクトリはすでに存在しているのか?
- もし存在するなら、そこにファイル(つまりページ)は置いているのか?
- もし存在するなら、既存のファイルを返す。
- ※ 誰かまたは何かがキャッシュ内のファイルを破損させた場合、エンドユーザーに問題を引き起こす可能性がある。ここで追加のチェックを行うことを現在検討している。
次のセクションでは、ファイルキャッシュをサーバーおよびデータベースと同期する方法について詳しく説明する。
Mutexによるファイルロックの実装
大量のリクエストが同時にギャラリーにアクセスしようとした場合はどうなる、想像できる?特に、キャッシュされていないギャラリーだと、不要な書き込みが発生し、その結果、キャッシュされたファイル自体が破損されてしまう可能性が高くなる。最悪のケースでは、アプリケーションやシステムを落とせるように悪用されることもあり得る。
これを防ぐために、ミューテックス(mutex)のマップ(map)を使用することにした。基本的に、すべてのキャッシュされたギャラリーとその最終アクセス時間を記録するランタイムマップを維持し、特定のギャラリーキャッシュが書き込まれているときにシグナルを送るミューテックスを使用する。
Initializing the map and reading the existing cache into the map by iterating and verifying the cache directory:
マップを初期化し、キャッシュディレクトリを反復して検証することで、既存のキャッシュをマップに読み込む。
ギャラリーは以下の関数で読み込まれ、ロジックは次のように進む:
- ギャラリーがランタイムマップに存在するか確認する。
- ない場合は、新しいエントリを作成する。
- ある場合は、アクセス時間のみを更新する。
- ミューテックスをロックして、ギャラリーの操作をブロックする。
- ギャラリーファイルがすでにキャッシュにあった場合は、ファイルパスだけを読み取ってファイル名を返す。存在しない場合は、ギャラリーを解凍してコピーする。
- ファイルパスとその数を返す。
- ミューテックスを解除する(deferステートメントに注目)。
最終アクセス時刻に基づいて期限切れのキャッシュエントリを削除するにも、ミューテックスの使用が必要。PruneCache関数は1分ごとに実行され、期限切れのギャラリーキャッシュをクリーンアップする。
PruneCache関数はランタイムマップ全体を反復し、間ミューテックスをロックしてアクセス時刻をチェックする。そして、期限が切れている場合は、キャッシュエントリをマップから削除してファイルも削除する。
安全にファイルを削除するために、パスが有効なUUIDで始まっていることを確認ができたファイルしか削除しないように注意している。
まとめ
この記事では、Goを使ったサーバー側のファイルキャッシングをミューテックスで実装する方法を紹介した。
ランタイムマップとミューテックスを使うことで、キャッシュされたギャラリーへの安全な同期アクセスを確保しながら、効率的にキャッシュを管理することができる。このアプローチは、サーバーの負荷を減らすだけでなく、同期アクセスやキャッシュの破損による潜在的な問題を防ぐことができる。
プロジェクトが進化するにつれて、新しい知見や改善点をこの記事に更新していく予定。
読んでいただきありがとうございます。このガイドが皆さんのプロジェクトに約立てたら嬉しい。
執筆Marko Leinikka
文字数:7701
11分で読めます