サーバ側の書込み保護のファイルキャッシュを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では両方、クライアントキャッシュとサーバキャッシュも併用している。クライアントの方は、ギャラリーごとにではなく、ページごとで管理されていて、ほぼ完全にブラウザに任せている形となっている。

サーバー側ファイルキャッシュの実装

... こちらの方は割と簡単にできる。

まず、キャッシュディレクトリを初期化する方法:

// InitPhysicalCache initializes the physical cache directories.
func InitPhysicalCache() {
	cachePath := config.BuildCachePath()
	if !utils.PathExists(cachePath) {
		err := os.Mkdir(cachePath, os.ModePerm)
		if err != nil {
		}
	}
 
	thumbnailsPath := config.BuildCachePath("thumbnails")
	if !utils.PathExists(thumbnailsPath) {
		err := os.Mkdir(thumbnailsPath, os.ModePerm)
		if err != nil {
		}
	}
}

次に、キャッシュから読み取るおよびキャッシュに書き込む関数の実装です。キャッシュに書き込む際には、ギャラリーを解凍する前に2つのチェックを行う:

  1. キャッシュディレクトリはすでに存在しているのか?
  2. もし存在するなら、そこにファイル(つまりページ)は置いているのか?
  3. もし存在するなら、既存のファイルを返す。
    • ※ 誰かまたは何かがキャッシュ内のファイルを破損させた場合、エンドユーザーに問題を引き起こす可能性がある。ここで追加のチェックを行うことを現在検討している。
// fetchFromDiskCache fetches the files from the disk cache and returns the list of files and the number of files.
func fetchFromDiskCache(dst string, uuid string) ([]string, int) {
	var files []string
	count := 0
 
	cacheWalk := func(s string, d fs.DirEntry, err error) error {
		if err != nil {
			log.Z.Error("failed to walk cache dir",
				zap.String("name", d.Name()),
				zap.String("err", err.Error()))
			return err
		}
		if d.IsDir() {
			return nil
		}
 
		// ReplaceAll ensures that the path is correct: cache/uuid/<arbitrary/path/image.png>
		files = append(files, strings.ReplaceAll(filepath.ToSlash(s), config.BuildCachePath(uuid)+"/", ""))
		count += 1
		return nil
	}
 
	err := filepath.WalkDir(dst, cacheWalk)
	if err != nil {
		log.Z.Error("failed to walk cache dir",
			zap.String("dst", dst),
			zap.String("err", err.Error()))
		return nil, 0
	}
 
	return files, count
}
 
// fetchOrExtractGallery extracts the gallery from the archive and returns the list of files and the number of files.
func fetchOrExtractGallery(archivePath string, uuid string) ([]string, int) {
	dst := config.BuildCachePath(uuid)
	if _, err := os.Stat(dst); errors.Is(err, fs.ErrNotExist) {
		return utils.UniversalExtract(dst, archivePath)
	}
 
	files, count := fetchFromDiskCache(dst, uuid)
	if count == 0 {
		err := os.Remove(dst)
		if err != nil {
			log.Z.Debug("removing empty cache dir failed",
				zap.String("dst", dst),
				zap.String("err", err.Error()))
			return nil, 0
		}
 
		return utils.UniversalExtract(dst, archivePath)
	}
	natsort.Sort(files)
 
	return files, count
}

次のセクションでは、ファイルキャッシュをサーバーおよびデータベースと同期する方法について詳しく説明する。

Mutexによるファイルロックの実装

大量のリクエストが同時にギャラリーにアクセスしようとした場合はどうなる、想像できる?特に、キャッシュされていないギャラリーだと、不要な書き込みが発生し、その結果、キャッシュされたファイル自体が破損されてしまう可能性が高くなる。最悪のケースでは、アプリケーションやシステムを落とせるように悪用されることもあり得る。

これを防ぐために、ミューテックス(mutex)のマップ(map)を使用することにした。基本的に、すべてのキャッシュされたギャラリーとその最終アクセス時間を記録するランタイムマップを維持し、特定のギャラリーキャッシュが書き込まれているときにシグナルを送るミューテックスを使用する。

type cacheValue struct {
	Accessed time.Time   // Used to determine when the cache entry was last accessed for expiring and pruning purposes.
	Mu       *sync.Mutex // Mutex to ensure file-write-safety when accessing the cache.
}
 
type GalleryCache struct {
	Path  string                // Path to the cache directory.
	Store map[string]cacheValue // Map of Mutexes and access times for each cache entry.
}
 
var galleryCache *GalleryCache

Initializing the map and reading the existing cache into the map by iterating and verifying the cache directory:

マップを初期化し、キャッシュディレクトリを反復して検証することで、既存のキャッシュをマップに読み込む。

// InitGalleryCache initializes the abstraction layer for the gallery cache.
func InitGalleryCache() {
	galleryCache = &GalleryCache{
		Path:  config.BuildCachePath(),
		Store: make(map[string]cacheValue),
	}
 
	iterateCacheEntries(func(pathToEntry string, accessTime time.Time) {
		maybeUUID := path.Base(pathToEntry)
		if _, err := uuid.Parse(maybeUUID); err != nil {
			return
		}
 
		galleryCache.Store[path.Base(pathToEntry)] = cacheValue{
			Accessed: accessTime,
			Mu:       &sync.Mutex{},
		}
	})
}
 
// iterateCacheEntries iterates over all cache entries and calls the callback function for each entry.
func iterateCacheEntries(callback func(pathToEntry string, accessTime time.Time)) {
	cachePath := config.BuildCachePath()
	cacheEntries, err := os.ReadDir(cachePath)
	if err != nil {
		log.Z.Error("could not read cache dir",
			zap.String("path", cachePath),
			zap.String("err", err.Error()))
		return
	}
 
	for _, entry := range cacheEntries {
		info, err := entry.Info()
		if err != nil {
			log.Z.Error("could to read cache entry info",
				zap.String("path", cachePath),
				zap.String("err", err.Error()))
			return
		}
 
		pathToEntry := path.Join(cachePath, entry.Name())
		accessTime, err := atime.Stat(pathToEntry)
		if err != nil {
			log.Z.Debug("could to read the access time",
				zap.String("name", entry.Name()),
				zap.String("path", cachePath),
				zap.String("err", err.Error()))
			accessTime = info.ModTime()
		}
 
		callback(pathToEntry, accessTime)
	}
}

ギャラリーは以下の関数で読み込まれ、ロジックは次のように進む:

  1. ギャラリーがランタイムマップに存在するか確認する。
    1. ない場合は、新しいエントリを作成する。
    2. ある場合は、アクセス時間のみを更新する。
  2. ミューテックスをロックして、ギャラリーの操作をブロックする。
  3. ギャラリーファイルがすでにキャッシュにあった場合は、ファイルパスだけを読み取ってファイル名を返す。存在しない場合は、ギャラリーを解凍してコピーする。
  4. ファイルパスとその数を返す。
  5. ミューテックスを解除する(deferステートメントに注目)。
// Read reads the gallery while updating the mutex and access time.
func Read(archivePath string, galleryUUID string) ([]string, int) {
	if _, ok := galleryCache.Store[galleryUUID]; !ok {
		galleryCache.Store[galleryUUID] = cacheValue{
			Accessed: time.Now(),
			Mu:       &sync.Mutex{},
		}
	} else {
		galleryCache.Store[galleryUUID] = cacheValue{
			Accessed: time.Now(),
			Mu:       galleryCache.Store[galleryUUID].Mu,
		}
	}
 
	galleryCache.Store[galleryUUID].Mu.Lock()
	defer galleryCache.Store[galleryUUID].Mu.Unlock()
 
	files, count := fetchOrExtractGallery(archivePath, galleryUUID)
	if count == 0 {
		return files, count
	}
 
	return files, count
}

最終アクセス時刻に基づいて期限切れのキャッシュエントリを削除するにも、ミューテックスの使用が必要。PruneCache関数は1分ごとに実行され、期限切れのギャラリーキャッシュをクリーンアップする。

utils.PeriodicTask(time.Minute, cache.PruneCache)
 
// PeriodicTask loops the given function in separate thread between the given interval.
func PeriodicTask(d time.Duration, f func()) {
	go func() {
		for {
			f()
			time.Sleep(d)
		}
	}()
}

PruneCache関数はランタイムマップ全体を反復し、間ミューテックスをロックしてアクセス時刻をチェックする。そして、期限が切れている場合は、キャッシュエントリをマップから削除してファイルも削除する。

安全にファイルを削除するために、パスが有効なUUIDで始まっていることを確認ができたファイルしか削除しないように注意している。

// PruneCache removes entries not accessed (internal timestamp in mem) in the last x time in a thread-safe manner.
func PruneCache() {
	now := time.Now()
	for galleryUUID, value := range galleryCache.Store {
		value.Mu.Lock()
		if value.Accessed.Add(config.Options.Cache.TTL).Before(now) {
			if err := remove(galleryUUID); err != nil {
				log.Z.Error("failed to delete a cache entry",
					zap.Bool("thread-safe", true),
					zap.String("uuid", galleryUUID),
					zap.String("err", err.Error()))
			}
		}
		value.Mu.Unlock()
	}
}
 
// remove wipes the cached gallery from the disk.
func remove(galleryUUID string) error {
	// Paranoid check to make sure that the base is a real UUID, since we don't want to delete anything else.
	maybeUUID := path.Base(galleryUUID)
	if _, err := uuid.Parse(maybeUUID); err != nil {
		delete(galleryCache.Store, galleryUUID)
		return err
	}
 
	galleryPath := config.BuildCachePath(galleryUUID)
	if err := os.RemoveAll(galleryPath); err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			delete(galleryCache.Store, galleryUUID)
		}
		return err
	}
 
	delete(galleryCache.Store, galleryUUID)
 
	return nil
}
 

まとめ

この記事では、Goを使ったサーバー側のファイルキャッシングをミューテックスで実装する方法を紹介した。 ランタイムマップとミューテックスを使うことで、キャッシュされたギャラリーへの安全な同期アクセスを確保しながら、効率的にキャッシュを管理することができる。このアプローチは、サーバーの負荷を減らすだけでなく、同期アクセスやキャッシュの破損による潜在的な問題を防ぐことができる。

プロジェクトが進化するにつれて、新しい知見や改善点をこの記事に更新していく予定。

読んでいただきありがとうございます。このガイドが皆さんのプロジェクトに約立てたら嬉しい。


執筆Marko Leinikka

文字数:7701
11分で読めます