paint-brush
FUSE と Node.js で動的ファイルシステムを構築する方法: 実践的なアプローチ@rglr
386 測定値
386 測定値

FUSE と Node.js で動的ファイルシステムを構築する方法: 実践的なアプローチ

Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

長すぎる; 読むには

sshfs user@remote:~/ /mnt/remoteroot を実行すると何が起こるのか疑問に思ったことはありませんか? リモート サーバーのファイルがローカル システムに表示され、すばやく同期されるのはなぜでしょうか? Wikipedia の記事を自分のファイル システム内のファイルであるかのように編集できる WikipediaFS について聞いたことがありますか? これは魔法ではなく、FUSE (Filesystem in Userspace) の力です。FUSE を使用すると、OS カーネルや低レベルのプログラミング言語に関する深い知識がなくても、独自のファイル システムを作成できます。この記事では、Node.js と TypeScript で FUSE を使用する実用的なソリューションを紹介します。FUSE が内部でどのように機能するかを探り、実際のタスクを解決することでそのアプリケーションを実演します。FUSE と Node.js の世界へのエキサイティングな冒険に私と一緒に参加してください。
featured image - FUSE と Node.js で動的ファイルシステムを構築する方法: 実践的なアプローチ
Aleksandr Zinin HackerNoon profile picture

sshfs user@remote:~/ /mnt/remoterootを実行すると何が起こるのか疑問に思ったことはありませんか? リモート サーバーのファイルがローカル システムに表示され、すばやく同期されるのはなぜでしょうか? WikipediaFSについて聞いたことがありますか? これを使用すると、Wikipedia の記事を自分のファイル システム内のファイルであるかのように編集できます。これは魔法ではなく、FUSE (Filesystem in Userspace) の力です。FUSE を使用すると、OS カーネルや低レベルのプログラミング言語に関する深い知識がなくても、独自のファイル システムを作成できます。


この記事では、Node.js と TypeScript で FUSE を使用する実用的なソリューションを紹介します。FUSE が内部でどのように動作するかを探り、実際のタスクを解決することでその応用例を示します。FUSE と Node.js の世界へのエキサイティングな冒険にご参加ください。

導入

私は仕事でメディア ファイル (主に画像) を担当していました。これには、サイド バナーやトップ バナー、チャット内のメディア、ステッカーなど、さまざまなものが含まれます。もちろん、これらには「バナーは PNG または WEBP、300 x 1000 ピクセル」など、多くの要件があります。要件が満たされていない場合、バック オフィスは画像を通過させません。また、オブジェクト重複排除メカニズムがあり、同じ画像を 2 回同じ川に入れることはできません。


これにより、テスト用に大量の画像セットを用意する状況になります。作業を簡単にするために、シェルのワンライナーまたはエイリアスを使用しました。


例えば:

 convert -size 300x1000 xc:gray +noise random /tmp/out.png


ノイズ画像の例


bashconvertの組み合わせは優れたツールですが、明らかに、これが問題に対処する最も便利な方法ではありません。QA チームの状況について話し合うと、さらに複雑な問題が明らかになります。画像生成にかなりの時間が費やされていることに加え、問題を調査するときに最初に尋ねられる質問は、「一意の画像をアップロードしたかどうか」です。これがどれほど面倒なことか、おわかりだと思います。

使いたい技術を選択しましょう

簡単な方法として、 GET /image/1000x100/random.zip?imagesCount=100のような、説明の不要なファイルを含むルートを提供する Web サービスを作成することもできます。このルートは、一意の画像のセットを含む ZIP ファイルを返します。これは良さそうですが、テストのためにアップロードされたすべてのファイルは一意である必要があるという主な問題には対処していません。


次に考えるのは、「送信時にペイロードを置き換えることはできますか?」ということでしょう。QAチームはAPI呼び出しにPostmanを使用しています。Postmanの内部を調べたところ、リクエスト本文を「オンザフライ」で変更することはできないことがわかりました。


もう 1 つの解決策は、ファイル システム内のファイルを読み取ろうとするたびに、そのファイルを置き換えることです。Linux には、ディレクトリの変更やファイルの変更などのファイル システム イベントについて警告する Inotify という通知サブシステムがあります。「Visual Studio Code はこの大きなワークスペース内のファイルの変更を監視できません」というメッセージが表示される場合は、Inotify に問題があります。ディレクトリが変更されたり、ファイルの名前が変更されたり、ファイルが開かれたりしたときに、イベントが起動される可能性があります。


イベントの完全なリストは、こちらでご覧いただけます: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html


したがって、計画は次のとおりです。

  1. IN_OPENイベントをリッスンし、ファイル記述子をカウントします。

  2. IN_CLOSEイベントをリッスンします。カウントが 0 に下がると、ファイルを置き換えます。


いい話に聞こえますが、これにはいくつか問題があります。

  • inotifyサポートするのは Linux のみです。
  • ファイルへの並列リクエストは同じデータを返す必要があります。
  • ファイルに大量の IO 操作がある場合、置換は行われません。
  • Inotify イベントを提供するサービスがクラッシュした場合、ファイルはユーザー ファイル システムに残ります。


これらの問題に対処するには、独自のファイル システムを作成することができます。ただし、別の問題もあります。通常のファイル システムは OS カーネル空間で実行されます。OS カーネルについて理解し、C/Rust などの言語を使用する必要があります。また、カーネルごとに特定のモジュール (ドライバー) を作成する必要があります。


したがって、ファイル システムを書くことは、たとえ長い週末が待ち構えていたとしても、私たちが解決したい問題にはやりすぎです。幸いにも、この厄介な問題を手懐ける方法があります。Filesystem in Use rspace (FUSE) です。FUSE は、カーネル コードを編集せずにファイル システムを作成できるプロジェクトです。つまり、FUSE を介したプログラムやスクリプトは、複雑なコア関連のロジックを必要とせずに、フラッシュ ドライブ、ハード ドライブ、または SSD をエミュレートできます。


言い換えれば、通常のユーザー空間プロセスは独自のファイルシステムを作成でき、Nautilus、Dolphin、ls などの通常のプログラムから通常どおりアクセスできます。


なぜ FUSE は要件を満たすのに適しているのでしょうか? FUSE ベースのファイル システムは、ユーザー空間のプロセス上に構築されます。したがって、 libfuseにバインドされている任意の言語を使用できます。また、FUSE を使用すると、クロスプラットフォーム ソリューションが得られます。


私は NodeJS と TypeScript について多くの経験があり、この (素晴らしい) 組み合わせを私たちの新しい FS の実行環境として選択したいと考えています。さらに、TypeScript は優れたオブジェクト指向の基盤を提供します。これにより、公開されている GitHub リポジトリにあるソース コードだけでなく、プロジェクトの構造も紹介できるようになります。

FUSE の詳細

公式 FUSE ページからの引用を紹介します。

FUSE は、ユーザー空間ファイルシステム フレームワークです。カーネル モジュール (fuse.ko)、ユーザー空間ライブラリ (libfuse.*)、マウント ユーティリティ (fusermount) で構成されています。


ファイルシステムを書き込むためのフレームワークは面白そうです。


各 FUSE 部分が何を意味するのか説明する必要があります。

  1. fuse.koカーネル関連の低レベルジョブをすべて実行します。これにより、OS カーネルへの介入を回避できます。


  2. libfusefuse.koとの通信のための高レベルレイヤーを提供するライブラリです。


  3. fusermount使用すると、ユーザーはユーザー空間のファイル システムをマウント/アンマウントできます (私のことを Captain Obvious と呼んでください)。


一般的な原則は次のようになります。
FUSEの一般原則


ユーザー空間プロセス (この場合はls ) は仮想ファイルシステムカーネルに要求を送信し、その要求は FUSE カーネルモジュールにルーティングされます。次に、FUSE モジュールは要求をユーザー空間のファイルシステム実現 (上の図では./hello ) にルーティングします。


仮想ファイルシステムという名前に惑わされないでください。これは FUSE と直接関係しているわけではありません。これはカーネル内のソフトウェア層であり、ユーザー空間プログラムにファイルシステム インターフェイスを提供します。簡単に言えば、これは複合パターンとして認識できます。


libfuse 、高レベルと低レベルの 2 種類の API を提供します。これらには類似点もありますが、決定的な違いもあります。低レベルの API は非同期であり、 inodesのみで動作します。この場合の非同期とは、低レベル API を使用するクライアントが応答メソッドを独自に呼び出す必要があることを意味します。


高レベルのものは、より「抽象的な」 inodesの代わりに便利なパス (たとえば、 /etc/shadow ) を使用する機能を提供し、同期的に応答を返します。この記事では、低レベルとinodesではなく、高レベルの仕組みについて説明します。


独自のファイル システムを実装する場合は、VFS からの要求を処理する一連のメソッドを実装する必要があります。最も一般的なメソッドは次のとおりです。


  • open(path, accessFlags): fd -- パスでファイルを開きます。このメソッドは、ファイル記述子 (以降fd ) と呼ばれる数値識別子を返します。アクセス フラグは、クライアント プログラムが実行する操作 (読み取り専用、書き込み専用、読み取り/書き込み、実行、または検索) を記述するバイナリ マスクです。


  • read(path, fd, Buffer, size, offset): count of bytes read - fdファイル記述子にリンクされたファイルから渡されたバッファにsizeバイトを読み取ります。fd を使用するため、 path引数は無視されます。


  • write(path, fd, Buffer, size, offset): count of bytes written - Buffer からsizeバイトをfdにリンクされたファイルに書き込みます。


  • release(fd) -- fdを閉じます。


  • truncate(path, size) -- ファイル サイズを変更します。ファイルを書き換える場合 (実際に書き換えます) は、このメソッドを定義する必要があります。


  • getattr(path) -- サイズ、作成日時、アクセス日時などのファイル パラメータを返します。このメソッドはファイル システムによって最も呼び出されやすいメソッドなので、最適なものを作成するようにしてください。


  • readdir(path) -- すべてのサブディレクトリを読み取ります。


上記のメソッドは、高レベルの FUSE API 上に構築された完全に操作可能な各ファイルシステムにとって不可欠です。ただし、リストは完全ではありません。完全なリストはhttps://libfuse.github.io/doxygen/structfuse__operations.htmlで確認できます。


ファイル記述子の概念をもう一度見てみましょう。MacOS を含む UNIX 系システムでは、ファイル記述子はファイルやソケット、パイプなどのその他の I/O リソースを抽象化したものです。プログラムがファイルを開くと、OS はファイル記述子と呼ばれる数値識別子を返します。この整数は、各プロセスの OS のファイル記述子テーブルのインデックスとして機能します。FUSE を使用してファイルシステムを実装する場合は、ファイル記述子を自分で生成する必要があります。


クライアントがファイルを開いたときの呼び出しフローを考えてみましょう。

  1. getattr(path: /random.png) → { size: 98 };クライアントはファイルサイズを取得しました。


  2. open(path: /random.png) → 10;パスでファイルを開きました。FUSE 実装はファイル記述子番号を返します。


  3. read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50;最初の50バイトを読み取ります。


  4. read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48;次の 50 を読み取ります。ファイル サイズのため 48 バイトが読み取られました。


  5. release(10);すべてのデータが読み取られたので、fdに近くなります。

最小限の機能を備えた製品を作成し、Postman の反応を確認してみましょう

次のステップは、 libfuseに基づく最小限のファイルシステムを開発し、Postman がカスタムファイルシステムとどのように対話するかをテストすることです。


FS の受け入れ要件は簡単です。FS のルートにはrandom.txtファイルが含まれている必要があります。このファイルのコンテンツは、読み取られるたびに一意である必要があります (これを「常に一意の読み取り」と呼びます)。コンテンツには、ランダムな UUID と ISO 形式の現在の時刻が新しい行で区切られて含まれている必要があります。例:

 3790d212-7e47-403a-a695-4d680f21b81c 2012-12-12T04:30:30


最小限の製品は 2 つの部分で構成されます。1 つ目は、HTTP POST リクエストを受け入れ、リクエスト本文を端末に出力するシンプルな Web サービスです。コードは非常にシンプルで、この記事は Express ではなく FUSE に関するものであるため、時間をかけるほどのものではありません。2 つ目の部分は、要件を満たすファイル システムの実装です。コードは 83 行しかありません。


コードでは、 libfuseの高レベル API へのバインディングを提供する node-fuse-bindings ライブラリを使用します。


以下のコードはスキップできます。以下にコードの概要を記述します。

 const crypto = require('crypto'); const fuse = require('node-fuse-bindings'); // MOUNT_PATH is the path where our filesystem will be available. For Windows, this will be a path like 'D://' const MOUNT_PATH = process.env.MOUNT_PATH || './mnt'; function getRandomContent() { const txt = [crypto.randomUUID(), new Date().toISOString(), ''].join('\n'); return Buffer.from(txt); } function main() { // fdCounter is a simple counter that increments each time a file is opened // using this we can get the file content, which is unique for each opening let fdCounter = 0; // fd2ContentMap is a map that stores file content by fd const fd2ContentMap = new Map(); // Postman does not work reliably if we give it a file with size 0 or just the wrong size, // so we precompute the file size // it is guaranteed that the file size will always be the same within one run, so there will be no problems with this const randomTxtSize = getRandomContent().length; // fuse.mount is a function that mounts the filesystem fuse.mount( MOUNT_PATH, { readdir(path, cb) { console.log('readdir(%s)', path); if (path === '/') { return cb(0, ['random.txt']); } return cb(0, []); }, getattr(path, cb) { console.log('getattr(%s)', path); if (path === '/') { return cb(0, { // mtime is the file modification time mtime: new Date(), // atime is the file access time atime: new Date(), // ctime is the metadata or file content change time ctime: new Date(), size: 100, // mode is the file access flags // this is a mask that defines access rights to the file for different types of users // and the type of file itself mode: 16877, // file owners // in our case, it will be the owner of the current process uid: process.getuid(), gid: process.getgid(), }); } if (path === '/random.txt') { return cb(0, { mtime: new Date(), atime: new Date(), ctime: new Date(), size: randomTxtSize, mode: 33188, uid: process.getuid(), gid: process.getgid(), }); } cb(fuse.ENOENT); }, open(path, flags, cb) { console.log('open(%s, %d)', path, flags); if (path !== '/random.txt') return cb(fuse.ENOENT, 0); const fd = fdCounter++; fd2ContentMap.set(fd, getRandomContent()); cb(0, fd); }, read(path, fd, buf, len, pos, cb) { console.log('read(%s, %d, %d, %d)', path, fd, len, pos); const buffer = fd2ContentMap.get(fd); if (!buffer) { return cb(fuse.EBADF); } const slice = buffer.slice(pos, pos + len); slice.copy(buf); return cb(slice.length); }, release(path, fd, cb) { console.log('release(%s, %d)', path, fd); fd2ContentMap.delete(fd); cb(0); }, }, function (err) { if (err) throw err; console.log('filesystem mounted on ' + MOUNT_PATH); }, ); } // Handle the SIGINT signal separately to correctly unmount the filesystem // Without this, the filesystem will not be unmounted and will hang in the system // If for some reason unmount was not called, you can forcibly unmount the filesystem using the command // fusermount -u ./MOUNT_PATH process.on('SIGINT', function () { fuse.unmount(MOUNT_PATH, function () { console.log('filesystem at ' + MOUNT_PATH + ' unmounted'); process.exit(); }); }); main();


ファイルの権限ビットに関する知識を新たにしておくことをお勧めします。権限ビットは、ファイルに関連付けられたビットのセットで、ファイルの読み取り/書き込み/実行を許可されているユーザーをバイナリで表したものです。「ユーザー」には、所有者、所有者グループ、その他の 3 つのグループが含まれます。


権限はグループごとに個別に設定できます。通常、各権限は 3 桁の数字で表されます。読み取り (2 進数では 4 または '100')、書き込み (2 または '010')、実行 (1 または '001') です。これらの数字を合計すると、複合権限が作成されます。たとえば、4 + 2 (または '100' + '010') は 6 ('110') になり、読み取り + 書き込み (RO) 権限を意味します。


ファイル所有者のアクセス マスクが 7 (2 進数で 111、読み取り、書き込み、実行を意味する) の場合、グループのアクセス マスクは 5 (101、読み取りと実行を意味する)、その他のアクセス マスクは 4 (100、読み取り専用を意味する) になります。したがって、ファイルの完全なアクセス マスクは 10 進数で 754 になります。実行権限はディレクトリの読み取り権限になることに注意してください。


ファイル システムの実装に戻り、これをテキスト バージョンで作成してみましょう。ファイルが開かれるたびに ( open呼び出しによって)、整数カウンタが増加し、open 呼び出しによって返されるファイル記述子が生成されます。次に、ランダムなコンテンツが作成され、ファイル記述子をキーとしてキー値ストアに保存されます。読み取り呼び出しが行われると、対応するコンテンツ部分が返されます。


release 呼び出し時に、コンテンツは削除されます。Ctrl+C を押した後、ファイルシステムをアンマウントするには、 SIGINT処理することを忘れないでください。それ以外の場合は、 fusermount -u ./MOUNT_PATHを使用してターミナルで手動で実行する必要があります。


さて、テストに移りましょう。Web サーバーを実行し、次の FS のルート フォルダーとして空のフォルダーを作成し、メイン スクリプトを実行します。「Server listening on port 3000」という行が印刷されたら、Postman を開き、パラメーターを変更せずに Web サーバーにいくつかのリクエストを連続して送信します。
左側がFS、右側がWebサーバーです


すべて順調です! 予想どおり、各リクエストには固有のファイル コンテンツがあります。ログは、上記の「FUSE の詳細」セクションで説明したファイル オープン呼び出しのフローが正しいことも証明しています。


MVP を含む GitHub リポジトリ: https://github.com/pinkiesky/node-fuse-mvp 。このコードをローカル環境で実行することも、このリポジトリを独自のファイル システム実装の定型文として使用することもできます。

核となるアイデア

アプローチは確認されました。次は主要な実装です。


「常に一意の読み取り」を実装する前に、最初に実装する必要があるのは、元のファイルの作成と削除の操作です。このインターフェイスは、仮想ファイルシステム内のディレクトリを通じて実装します。ユーザーは、「常に一意」または「ランダム化」する元のイメージを配置し、ファイルシステムが残りの部分を準備します。


ここで、および以降のセクションでは、「常に一意の読み取り」、「ランダム イメージ」、または「ランダム ファイル」とは、読み取られるたびにバイナリの意味で一意のコンテンツを返すが、視覚的には元のファイルと可能な限り類似したファイルを指します。


ファイル システムのルートには、Image Manager と Images の 2 つのディレクトリが含まれます。最初のディレクトリは、ユーザーの元のファイルを管理するためのフォルダーです (CRUD リポジトリと考えることができます)。2 番目のディレクトリは、ランダムな画像が含まれる、ユーザーの観点からは管理されていないディレクトリです。
ユーザーがファイルシステムを操作する


ターミナル出力としてのFSツリー


上の画像からわかるように、「常に一意」な画像だけでなく、ファイルコンバーターも実装します。これは追加のボーナスです。


私たちの実装の核となる考え方は、プログラムにオブジェクト ツリーが含まれ、各ノードとリーフが共通の FUSE メソッドを提供するというものです。プログラムが FS 呼び出しを受け取ると、対応するパスによってツリー内のノードまたはリーフを検索する必要があります。たとえば、プログラムはgetattr(/Images/1/original/)呼び出しを取得し、パスがアドレス指定されているノードを検索しようとします。


このようなもの: FSツリーの例


次の問題は、元の画像をどのように保存するかです。プログラム内の画像は、バイナリ データとメタ情報 (メタには元のファイル名、ファイルの MIME タイプなどが含まれます) で構成されます。バイナリ データはバイナリ ストレージに保存されます。これを単純化して、バイナリ ストレージをユーザー (またはホスト) ファイル システムのバイナリ ファイルのセットとして構築しましょう。メタ情報も同様に保存されます。つまり、ユーザー ファイル システムのテキスト ファイル内の JSON です。


覚えているかもしれませんが、「最小限の実行可能な製品を作成しましょう」セクションでは、テンプレートによってテキスト ファイルを返すファイル システムを作成しました。このファイル システムにはランダムな UUID と現在の日付が含まれているため、データの一意性は問題ではありませんでした。一意性はデータの定義によって実現されました。ただし、この時点から、プログラムは事前にロードされたユーザー イメージで動作するはずです。では、元のイメージに基づいて、類似しているが常に一意である (バイト数とハッシュの点で) イメージを作成するにはどうすればよいでしょうか。


私が提案する解決策は非常に簡単です。画像の左上隅に RGB ノイズ スクエアを配置します。ノイズ スクエアは 16 x 16 ピクセルにする必要があります。これにより、ほぼ同じ画像が得られますが、一意のバイト シーケンスが保証されます。これで、さまざまな画像を確実に作成できるでしょうか。計算してみましょう。スクエアのサイズは 16 です。1 つのスクエアに 16 x 16 = 256 個の RGB ピクセルがあります。各ピクセルには 256 x 256 x 256 = 16,777,216 個のバリエーションがあります。


したがって、ユニークな正方形の数は 16,777,216^256 です。これは 1,558 桁の数字で、観測可能な宇宙の原子の数よりもはるかに多い数です。これは、正方形のサイズを縮小できることを意味しますか? 残念ながら、JPEG などの非可逆圧縮ではユニークな正方形の数が大幅に減少するため、16x16 が最適なサイズです。


ノイズのある画像の例

クラスを越えたパススルー


FUSE ベースのシステムのインターフェイスとクラスを示す UML クラス図。インターフェイス IFUSEHandler、ObjectTreeNode、IFUSETreeNode が含まれており、FileFUSETreeNode と DirectoryFUSETreeNode は IFUSETreeNode を実装しています。各インターフェイスとクラスには属性とメソッドがリストされ、それらの関係と階層が示されています。

IFUSEHandler 、一般的な FUSE 呼び出しを提供するインターフェースです。read read/writeをそれぞれreadAll/writeAllに置き換えたことがわかります。これは、読み取りと書き込みの操作を簡素化するためです。IFUSEHandler IFUSEHandler部分全体に対して読み取り/書き込みを行うと、部分的な読み取り/書き込みロジックを別の場所に移動できます。つまり、 IFUSEHandlerファイル記述子やバイナリ データなどについて何も知る必要がありません。


同じことがopen FUSE メソッドでも起こりました。ツリーの注目すべき点は、オンデマンドで生成されることです。ツリー全体をメモリに保存するのではなく、プログラムはアクセスされたときにのみノードを作成します。この動作により、プログラムはノードの作成または削除の際にツリーの再構築に関する問題を回避できます。


ObjectTreeNodeインターフェイスを確認すると、 children配列ではなくメソッドであることがわかります。これは、それらがオンデマンドで生成される方法です。FileFUSETreeNode とFileFUSETreeNode DirectoryFUSETreeNode抽象クラスであり、一部のメソッドはNotSupportedエラーをスローします (明らかに、 FileFUSETreeNode readdirを実装してはなりません)。

FUSEファサード

FUSE システムのインターフェースとその関係を示す UML クラス図。この図には、IFUSEHandler、IFUSETreeNode、IFileDescriptorStorage インターフェース、および FUSEFacade クラスが含まれています。IFUSEHandler には、属性 name とメソッド checkAvailability、create、getattr、readAll、remove、writeAll があります。IFileDescriptorStorage には、メソッド get、openRO、openWO、release があります。IFUSETreeNode は、IFUSEHandler を拡張します。FUSEFacade には、constructor、create、getattr、open、read、readdir、release、rmdir、safeGetNode、unlink、write メソッドが含まれており、IFUSETreeNode と IFileDescriptorStorage の両方と対話します。


FUSEFacade は、プログラムのメインロジックを実装し、さまざまな部分を結合する最も重要なクラスです。node node-fuse-bindingsコールバックベースの API がありますが、FUSEFacade メソッドは Promise ベースのものを使用して作成されます。この不便さを解決するために、次のようなコードを使用しました。

 const handleResultWrapper = <T>( promise: Promise<T>, cb: (err: number, result: T) => void, ) => { promise .then((result) => { cb(0, result); }) .catch((err) => { if (err instanceof FUSEError) { fuseLogger.info(`FUSE error: ${err}`); return cb(err.code, null as T); } fuseLogger.warn(err); cb(fuse.EIO, null as T); }); }; // Ex. usage: // open(path, flags, cb) { // handleResultWrapper(fuseFacade.open(path, flags), cb); // },


FUSEFacadeメソッドはhandleResultWrapperにラップされています。パスを使用するFUSEFacadeの各メソッドは、パスを解析し、ツリー内のノードを見つけて、要求されたメソッドを呼び出します。


FUSEFacadeクラスのいくつかのメソッドを検討します。

 async create(path: string, mode: number): Promise<number> { this.logger.info(`create(${path})`); // Convert path `/Image Manager/1/image.jpg` in // `['Image Manager', '1', 'image.jpg']` // splitPath will throw error if something goes wrong const parsedPath = this.splitPath(path); // `['Image Manager', '1', 'image.jpg']` const name = parsedPath.pop()!; // 'image.jpg' // Get node by path (`/Image Manager/1` after `pop` call) // or throw an error if node not found const node = await this.safeGetNode(parsedPath); // Call the IFUSEHandler method. Pass only a name, not a full path! await node.create(name, mode); // Create a file descriptor const fdObject = this.fdStorage.openWO(); return fdObject.fd; } async readdir(path: string): Promise<string[]> { this.logger.info(`readdir(${path})`); const node = await this.safeGetNode(path); // As you see, the tree is generated on the fly return (await node.children()).map((child) => child.name); } async open(path: string, flags: number): Promise<number> { this.logger.info(`open(${path}, ${flags})`); const node = await this.safeGetNode(path); // A leaf node is a directory if (!node.isLeaf) { throw new FUSEError(fuse.EACCES, 'invalid path'); } // Usually checkAvailability checks access await node.checkAvailability(flags); // Get node content and put it in created file descriptor const fileData: Buffer = await node.readAll(); // fdStorage is IFileDescriptorStorage, we will consider it below const fdObject = this.fdStorage.openRO(fileData); return fdObject.fd; }

ファイル記述子

次のステップに進む前に、プログラムのコンテキストにおけるファイル記述子が何であるかを詳しく見てみましょう。

UML クラス図は、FUSE システムのファイル記述子のインターフェイスとその関係を示しています。図には、IFileDescriptor、IFileDescriptorStorage インターフェイス、および ReadWriteFileDescriptor、ReadFileDescriptor、WriteFileDescriptor クラスが含まれています。IFileDescriptor には、binary、fd、size 属性と、readToBuffer、writeToBuffer メソッドがあります。IFileDescriptorStorage には、get、openRO、openWO、release メソッドがあります。ReadWriteFileDescriptor は、追加のコンストラクター、readToBuffer、writeToBuffer メソッドを使用して IFileDescriptor を実装します。ReadFileDescriptor と WriteFileDescriptor は ReadWriteFileDescriptor を拡張し、ReadFileDescriptor には writeToBuffer メソッドがあり、WriteFileDescriptor には readToBuffer メソッドがあります。

ReadWriteFileDescriptor 、ファイル記述子を数値として、バイナリ データをバッファーとして保存するクラスです。このクラスには、ファイル記述子バッファーにデータを読み書きする機能を提供するreadToBuffer ReadFileDescriptorwriteToBufferメソッドがあります。ReadFileDescriptor とWriteFileDescriptor 、読み取り専用および書き込み専用の記述子の実装です。


IFileDescriptorStorageは、ファイル記述子のストレージを記述するインターフェイスです。プログラムには、このインターフェイスの実装が 1 つだけあります: InMemoryFileDescriptorStorage 。名前からわかるように、記述子の永続性は必要ないため、ファイル記述子をメモリに保存します。


FUSEFacadeファイル記述子とストレージをどのように使用するかを確認しましょう。

 async read( fd: number, // File descriptor to read from buf: Buffer, // Buffer to store the read data len: number, // Length of data to read pos: number, // Position in the file to start reading from ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Read data into the buffer and return the number of bytes read return fdObject.readToBuffer(buf, len, pos); } async write( fd: number, // File descriptor to write to buf: Buffer, // Buffer containing the data to write len: number, // Length of data to write pos: number, // Position in the file to start writing at ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Write data from the buffer and return the number of bytes written return fdObject.writeToBuffer(buf, len, pos); } async release(path: string, fd: number): Promise<0> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Safely get the node corresponding to the file path const node = await this.safeGetNode(path); // Write all the data from the file descriptor object to the node await node.writeAll(fdObject.binary); // Release the file descriptor from storage this.fdStorage.release(fd); // Return 0 indicating success return 0; }


上記のコードは簡単です。ファイル記述子の読み取り、書き込み、解放を行うメソッドを定義し、操作を実行する前にファイル記述子が有効であることを確認します。また、解放メソッドは、ファイル記述子オブジェクトからファイルシステムノードにデータを書き込み、ファイル記述子を解放します。


libfuseとツリーに関するコードはこれで完了です。次は、イメージ関連のコードに取り掛かります。

画像:「データ転送オブジェクト」部分
UML クラス図は、画像処理のインターフェイスとその関係を示しています。図には、ImageBinary、ImageMeta、Image、および IImageMetaStorage インターフェイスが含まれています。ImageBinary には、属性 buffer および size があります。ImageMeta には、属性 id、name、originalFileName、および originalFileType があります。Image には、属性 binary および meta があります。ここで、binary は ImageBinary 型で、meta は ImageMeta 型です。IImageMetaStorage には、メソッド create、get、list、および remove があります。


ImageMetaは、画像に関するメタ情報を保存するオブジェクトです。IImageMetaStorage は、メタのストレージを記述するインターフェイスです。プログラムにはIImageMetaStorageインターフェイスの実装が 1 つだけあります。FSImageMetaStorage クラスは、 IImageMetaStorageインターフェイスを実装して、単一の JSON ファイルにFSImageMetaStorageされている画像メタデータを管理します。


キャッシュを使用してメモリにメタデータを保存し、必要に応じて JSON ファイルから読み取ることでキャッシュが確実にハイドレートされるようにします。このクラスは、イメージ メタデータを作成、取得、一覧表示、削除するメソッドを提供し、変更を JSON ファイルに書き戻して更新を永続化します。キャッシュにより、IO 操作回数が削減され、パフォーマンスが向上します。


ImageBinary 、明らかにバイナリ画像データを持つオブジェクトです。 Imageインターフェースは、 ImageMetaImageBinaryの組み合わせです。

画像: バイナリ ストレージとジェネレーター

UML クラス図は、画像生成とバイナリ ストレージのインターフェイスとその関係を示しています。図には、IBinaryStorage、IImageGenerator インターフェイス、および FSBinaryStorage、ImageGeneratorComposite、PassThroughImageGenerator、TextImageGenerator、および ImageLoaderFacade クラスが含まれています。IBinaryStorage には、load、remove、および write メソッドがあります。FSBinaryStorage は IBinaryStorage を実装し、追加のコンストラクターがあります。IImageGenerator には、generate メソッドがあります。PassThroughImageGenerator と TextImageGenerator は IImageGenerator を実装しています。ImageGeneratorComposite には、addGenerator メソッドと generate メソッドがあります。ImageLoaderFacade には、コンストラクターと load メソッドがあり、IBinaryStorage および IImageGenerator と対話します。


IBinaryStorageバイナリ データ ストレージ用のインターフェイスです。バイナリ ストレージは画像から切り離され、画像、ビデオ、JSON、テキストなど、あらゆるデータを保存できます。これは私たちにとって重要なことであり、その理由はすぐにわかります。


IImageGeneratorは、ジェネレーターを記述するインターフェイスです。ジェネレーターはプログラムの重要な部分です。ジェネレーターは、生のバイナリ データとメタを受け取り、それに基づいて画像を生成します。プログラムにジェネレーターが必要なのはなぜですか? ジェネレーターがなくてもプログラムは動作しますか?


可能ですが、ジェネレーターを使用すると実装に柔軟性が追加されます。ジェネレーターを使用すると、ユーザーは画像、テキスト データ、および広く言えば、ジェネレーターを記述する任意のデータをアップロードできます。


IImageGenerator インターフェイスを使用してテキスト ファイルを画像に変換するプロセスを示す図。左側には、「myfile.txt」というラベルの付いたテキスト ファイルのアイコンがあり、コンテンツは「Hello, world!」です。「IImageGenerator」というラベルの付いた矢印が右側を指しており、そこには「myfile.png」というラベルの付いた画像ファイルのアイコンがあり、画像に表示されているのと同じテキスト「Hello, world!」があります。


フローは次のようになります。バイナリ データがストレージ (上の図のmyfile.txt ) からロードされ、バイナリがジェネレーターに渡されます。ジェネレーターは画像を「オンザフライ」で生成します。これは、私たちにとってより便利な、ある形式から別の形式へのコンバーターとして捉えることができます。


ジェネレーターの例を見てみましょう:

 import { createCanvas } from 'canvas'; // Import createCanvas function from the canvas library to create and manipulate images const IMAGE_SIZE_RE = /(\d+)x(\d+)/; // Regular expression to extract width and height dimensions from a string export class TextImageGenerator implements IImageGenerator { // method to generate an image from text async generate(meta: ImageMeta, rawBuffer: Buffer): Promise<Image | null> { // Step 1: Verify the MIME type is text if (meta.originalFileType !== MimeType.TXT) { // If the file type is not text, return null indicating no image generation return null; } // Step 2: Determine the size of the image const imageSize = { width: 800, // Default width height: 600, // Default height }; // Extract dimensions from the name if present const imageSizeRaw = IMAGE_SIZE_RE.exec(meta.name); if (imageSizeRaw) { // Update the width and height based on extracted values, or keep defaults imageSize.width = Number(imageSizeRaw[1]) || imageSize.width; imageSize.height = Number(imageSizeRaw[2]) || imageSize.height; } // Step 3: Convert the raw buffer to a string to get the text content const imageText = rawBuffer.toString('utf-8'); // Step 4: Create a canvas with the determined size const canvas = createCanvas(imageSize.width, imageSize.height); const ctx = canvas.getContext('2d'); // Get the 2D drawing context // Step 5: Prepare the canvas background ctx.fillStyle = '#000000'; // Set fill color to black ctx.fillRect(0, 0, imageSize.width, imageSize.height); // Fill the entire canvas with the background color // Step 6: Draw the text onto the canvas ctx.textAlign = 'start'; // Align text to the start (left) ctx.textBaseline = 'top'; // Align text to the top ctx.fillStyle = '#ffffff'; // Set text color to white ctx.font = '30px Open Sans'; // Set font style and size ctx.fillText(imageText, 10, 10); // Draw the text with a margin // Step 7: Convert the canvas to a PNG buffer and create the Image object return { meta, // Include the original metadata binary: { buffer: canvas.toBuffer('image/png'), // Convert canvas content to a PNG buffer }, }; } }


ImageLoaderFacadeクラスは、ストレージとジェネレーターを論理的に組み合わせたファサードです。つまり、上で読んだフローを実装します。

画像: バリエーション

UML クラス図は、画像生成とバイナリ ストレージのインターフェイスとその関係を示しています。図には、IBinaryStorage、IImageGenerator インターフェイス、および FSBinaryStorage、ImageGeneratorComposite、PassThroughImageGenerator、TextImageGenerator、および ImageLoaderFacade クラスが含まれています。IBinaryStorage には、load、remove、および write メソッドがあります。FSBinaryStorage は IBinaryStorage を実装し、追加のコンストラクターがあります。IImageGenerator には、generate メソッドがあります。PassThroughImageGenerator と TextImageGenerator は IImageGenerator を実装しています。ImageGeneratorComposite には、addGenerator メソッドと generate メソッドがあります。ImageLoaderFacade には、コンストラクターと load メソッドがあり、IBinaryStorage および IImageGenerator と対話します。


IImageVariant 、さまざまなイメージ バリアントを作成するためのインターフェイスです。このコンテキストでは、バリアントとは、ファイル システム内のファイルを表示するときにユーザーに表示される、オンザフライで生成されるイメージです。ジェネレータとの主な違いは、生データではなくイメージを入力として受け取ることです。


プログラムには、 ImageAlwaysRandomImageOriginalVariantImageWithText 3 つのバリアントがあります。 ImageAlwaysRandom 、ランダムな RGB ノイズ スクエアを含む元の画像を返します。


 export class ImageAlwaysRandomVariant implements IImageVariant { // Define a constant for the size of the random square edge in pixels private readonly randomSquareEdgeSizePx = 16; // Constructor takes the desired output format for the image constructor(private readonly outputFormat: ImageFormat) {} // Asynchronous method to generate a random variant of an image async generate(image: Image): Promise<ImageBinary> { // Step 1: Load the image using the sharp library const sharpImage = sharp(image.binary.buffer); // Step 2: Retrieve metadata and raw buffer from the image const metadata = await sharpImage.metadata(); // Get image metadata const buffer = await sharpImage.raw().toBuffer(); // Get raw pixel data // the buffer size is plain array with size of image width * image height * channels count (3 or 4) // Step 3: Apply random pixel values to a small square region in the image for (let y = 0; y < this.randomSquareEdgeSizePx; y++) { for (let x = 0; x < this.randomSquareEdgeSizePx; x++) { // Calculate the buffer offset for the current pixel const offset = y * metadata.width! * metadata.channels! + x * metadata.channels!; // Set random values for RGB channels buffer[offset + 0] = randInt(0, 255); // Red channel buffer[offset + 1] = randInt(0, 255); // Green channel buffer[offset + 2] = randInt(0, 255); // Blue channel // If the image has an alpha channel, set it to 255 (fully opaque) if (metadata.channels === 4) { buffer[offset + 3] = 255; // Alpha channel } } } // Step 4: Create a new sharp image from the modified buffer and convert it to the desired format const result = await sharp(buffer, { raw: { width: metadata.width!, height: metadata.height!, channels: metadata.channels!, }, }) .toFormat(this.outputFormat) // Convert to the specified output format .toBuffer(); // Get the final image buffer // Step 5: Return the generated image binary data return { buffer: result, // Buffer containing the generated image }; } }


NodeJS で画像を操作する最も便利な方法として、 sharpライブラリを使用します: https://github.com/lovell/sharp


ImageOriginalVariant 、変更されていない画像を返します (ただし、異なる圧縮形式で画像を返すこともできます)。ImageWithText ImageWithText 、テキストが書き込まれた画像を返します。これは、1 つの画像の定義済みバリエーションを作成するときに役立ちます。たとえば、1 つの画像のランダムなバリエーションが 10 個必要な場合は、これらのバリエーションを互いに区別する必要があります。


ここでの解決策は、オリジナルに基づいて 10 枚の画像を作成し、各画像の左上隅に 0 から 9 までの連番をレンダリングすることです。

大きな目をした白と黒の猫の画像の連続。画像には、左から 0 から始まり、1 ずつ増え、右の 9 まで省略記号で続く番号が付けられています。猫の表情はどの画像でも同じです。


ImageCacheWrapperバリアントとは異なる目的を持ち、特定のIImageVariantクラスの結果をキャッシュするラッパーとして機能します。これは、画像コンバーター、テキストから画像へのジェネレーターなど、変更されないエンティティをラップするために使用されます。このキャッシュ メカニズムにより、主に同じ画像が複数回読み取られる場合に、データの取得が高速化されます。


さて、プログラムの主要な部分はすべて説明しました。次は、すべてをまとめる番です。

ツリー構造

UML クラス図は、イメージ管理に関連するさまざまな FUSE ツリー ノード間の階層と関係を示しています。クラスには、ImageVariantFileFUSETreeNode、ImageCacheWrapper、ImageItemAlwaysRandomDirFUSETreeNode、ImageItemOriginalDirFUSETreeNode、ImageItemCounterDirFUSETreeNode、ImageManagerItemFileFUSETreeNode、ImageItemDirFUSETreeNode、ImageManagerDirFUSETreeNode、ImagesDirFUSETreeNode、および RootDirFUSETreeNode が含まれます。各クラスには、イメージ メタデータ、バイナリ データ、および create、readAll、writeAll、remove、および getattr などのファイル操作に関連する属性とメソッドがあります。


以下のクラス図は、ツリー クラスがイメージ クラスとどのように結合されるかを表しています。図は下から上に読み進めてください。RootDir (名前にFUSETreeNode接尾辞ImagesManagerDir RootDirないようにします) は、プログラムが実装しているファイル システムのルート ディレクトリです。上の行に移動すると、2 つのディレクトリ、 ImagesDirImagesManagerDirがあります。ImagesManagerDir にはユーザー イメージ リストが含まれており、これらを制御できます。次に、 ImagesManagerItemFileは特定のファイルのノードです。このクラスは CRUD 操作を実装します。


ノードの通常の実装として ImagesManagerDir を考えてみましょう。

 class ImageManagerDirFUSETreeNode extends DirectoryFUSETreeNode { name = 'Image Manager'; // Name of the directory constructor( private readonly imageMetaStorage: IImageMetaStorage, private readonly imageBinaryStorage: IBinaryStorage, ) { super(); // Call the parent class constructor } async children(): Promise<IFUSETreeNode[]> { // Dynamically create child nodes // In some cases, dynamic behavior can be problematic, requiring a cache of child nodes // to avoid redundant creation of IFUSETreeNode instances const list = await this.imageMetaStorage.list(); return list.map( (meta) => new ImageManagerItemFileFUSETreeNode( this.imageMetaStorage, this.imageBinaryStorage, meta, ), ); } async create(name: string, mode: number): Promise<void> { // Create a new image metadata entry await this.imageMetaStorage.create(name); } async getattr(): Promise<Stats> { return { // File modification date mtime: new Date(), // File last access date atime: new Date(), // File creation date // We do not store dates for our images, // so we simply return the current date ctime: new Date(), // Number of links nlink: 1, size: 100, // File access flags mode: FUSEMode.directory( FUSEMode.ALLOW_RWX, // Owner access rights FUSEMode.ALLOW_RX, // Group access rights FUSEMode.ALLOW_RX, // Access rights for all others ), // User ID of the file owner uid: process.getuid ? process.getuid() : 0, // Group ID for which the file is accessible gid: process.getgid ? process.getgid() : 0, }; } // Explicitly forbid deleting the 'Images Manager' folder remove(): Promise<void> { throw FUSEError.accessDenied(); } }


先に進むと、 ImagesDirユーザーの画像にちなんで名付けられたサブディレクトリが含まれます。ImagesItemDir ImagesItemDir各ディレクトリを担当します。ImagesItemDir には、使用可能なすべてのバリアントが含まれます。覚えているように、バリアントの数は 3 です。各バリアントは、さまざまな形式 (現在は jpeg、png、webm) の最終的な画像ファイルを含むディレクトリです。ImagesItemOriginalDir とImagesItemOriginalDir ImagesItemCounterDir 、生成されたすべてのImageVariantFileインスタンスをキャッシュにラップします。


エンコードは CPU を消費するため、元のイメージを頻繁に再エンコードすることを避けるためにこれが必要です。図の一番上にあるのはImageVariantFileです。これは実装の最高傑作であり、前述のIFUSEHandlerIImageVariantの組み合わせです。これは、私たちがこれまで構築してきたすべての努力の成果です。

テスト

最終的なファイルシステムが同じファイルへの並列リクエストをどのように処理するかをテストしてみましょう。これを行うには、複数のスレッドでmd5sumユーティリティを実行し、ファイルシステムからファイルを読み取り、そのハッシュを計算します。次に、これらのハッシュを比較します。すべてが正しく動作していれば、ハッシュは異なるはずです。

 #!/bin/bash # Loop to run the md5sum command 5 times in parallel for i in {1..5} do echo "Run $i..." # `&` at the end of the command runs it in the background md5sum ./mnt/Images/2020-09-10_22-43/always_random/2020-09-10_22-43.png & done echo 'wait...' # Wait for all background processes to finish wait


スクリプトを実行し、次の出力を確認しました (わかりやすくするために少し整理しました)。

 Run 1... Run 2... Run 3... Run 4... Run 5... wait... bcdda97c480db74e14b8779a4e5c9d64 0954d3b204c849ab553f1f5106d576aa 564eeadfd8d0b3e204f018c6716c36e9 73a92c5ef27992498ee038b1f4cfb05e 77db129e37fdd51ef68d93416fec4f65


素晴らしい! すべてのハッシュが異なるため、ファイルシステムは毎回一意のイメージを返します。

結論

この記事が、皆さんが独自の FUSE 実装を作成するきっかけになれば幸いです。このプロジェクトのソース コードは、https: //github.com/pinkiesky/node-fuse-imagesから入手できます。


私たちが構築したファイルシステムは、FUSE と Node.js を使用した作業の基本原則を示すために簡略化されています。たとえば、正しい日付は考慮されません。拡張の余地は十分にあります。ユーザーの GIF ファイルからのフレーム抽出、ビデオのトランスコーディング、さらにはワーカーによるタスクの並列化などの機能を追加することを想像してみてください。


しかし、完璧は良さの敵です。まずは手元にあるものから始めて、動作させてから繰り返しましょう。コーディングを楽しんでください!