Iwate Developers Advent Calendar 2014 ですが,ネタは岩手と関係ありません.
書いている人が岩手出身・岩手の大学に通っていたというだけです.
時間切れで記事は書き途中です.ご了承ください.
はじめに
MicrosoftはOffice365ユーザのOneDrive容量を無制限にするというニュースが少し前に発表されました.
こういうのを聞いてしまうと,無限に円周率や乱数をアップロードしたくなってきますよね.
しかし,OneDriveには以下のように使いづらい点があります.
- Linux向けのクライアントがない
- ファイルサイズの制限がある (100MB/ファイル)
- 平文で保存される
こういう時に便利なのが,FUSEやDokanです.これらを使うことで簡単に仮想ファイルシステムを作ることができます.また,OneDriveはREST APIを公開しています.
そこで今回は,OneDriveをバックエンドに上記課題を解決するファイルシステムの作り方を通して,OneDriveのREST APIの使い方やFUSE(mono-fuse),Dokanを紹介したいと思います.
なお,実装言語はFUSEおよびDokanの両方のバインディングがあるC#を利用しています.
12/20時点のソースは以下に公開しています.
https://github.com/kazuki/EncryptedOneDrive/commit/d1c7630b8ca2f4489dceafd5c241ca44c0b0b278
12/21時点でのソース.暗号処理およびFUSE経由でのファイルの読み書きに対応しました(が,FUSE経由の読み込み処理はシーケンシャルリードであってもシークが走ってしまうため,上手く動いていません)
https://github.com/kazuki/EncryptedOneDrive/commit/cd0de5ccd1dcf8dc57d81fb829eda0176c97d556
仮想ファイルシステム
今日は時間の都合もあるので以下のような簡単なものです.
- 同時に複数箇所から利用することは考慮しない
- ファイルシステムのフォルダ・ファイルツリー構造は全部メモリ上で保持する
- ツリー構造やメタ情報への更新は追記形式のログとしてOneDriveに定期的にアップロード.チェックポイント処理は適当に…
- ファイルサイズの制限を超えないファイルは,1ファイルとしてOneDriveにアップロード(とても小さいファイルを束ねたりはしない)
- ファイルサイズの制限を超えるファイルは,複数ファイルに分割してアップロード
- アップロードするサイズまではメモリでバッファリングする
OneDrive上のディレクトリ構造は以下のような感じ
- /encrypted/meta/logs.[0-9a-f]{16}: ログ情報.ピリオド以下の桁数は固定で64bitからなるバージョン番号を16進数で表した値
- /encrypted/meta/checkpoint.[0-9a-f]{16}: チェックポイント情報.ピリオド以下のバージョンまでの情報を束ねたもの
- /encrypted/data/[0-9a-f]{2}/[0-9a-f]{62}: HMAC(SHA256)のハッシュ値でフォルダ・ファイル名を決定する
OneDrive REST API
OAuth 2.0
OneDriveはLive ConnectのOAuth2.0を使っています.
デスクトップアプリケーションの場合は,GUIにブラウザを内蔵して,https://login.live.com/oauth20_desktop.srf へ
リダイレクトされたことを検出し,その時のフラグメント(URLの#以降の文字列)に書かれているトークンを取得することでトークンを
取得しますが,ポータブルなアプリケーションではちょっと面倒です.
だからといって,リダイレクトURLにはローカルホストは指定できませんし,ポート番号も登録する必要があるので,
アプリ内でHTTPサーバをエフェメラルなポート番号で立てて,そこへリダイレクトさせるという手段も使えません.
今回は時間の都合上,ここの部分を作りこむことは出来ないので,何らかの方法でトークンを用意してもらって
アプリケーションからはそのトークンをファイルから読み込むという方法を使うことにしました.
なお,必要なスコープは
- wl.signin
- wl.basic
- wl.contacts_skydrive
- wl.skydrive_update
かな?wl.signinとwl.basicが必要かどうかは確認していませんが...
フォルダ・ファイルの列挙方法
OneDriveのREST APIは,ちょっと特殊でディレクトリツリーはあるけど,内部表現はフラットな構造になっています.なので,/dir1/dir2 というフォルダの中のファイル一覧を取得したいときは,
https://apis.live.net/v5.0/me/skydrive/files にアクセスし,ルートのファイル一覧よりdir1のフォルダIDを取得し,
https://apis.live.net/v5.0/folder.0123456789/files にアクセスし,dir1のファイル一覧よりdir2のフォルダIDを取得し...
と再帰的に呼び出す必要があります.バックエンドはオブジェクトストアなのでこのような内部表現になっているのかな?
パスに対応するIDをキャッシュとして保持するようにし,pathからファイル・フォルダIDを取得する
Resolveメソッドはだいたい以下のような感じ.詳細or最新版はGitHubの方を参照ください
string Resolve (string path)
{
if (!path.StartsWith ("/", StringComparison.InvariantCulture) && path.Length > 0) // 相対パスは弾く
throw new ArgumentException ();
if (path.Length <= 1) { // empty or "/" はルートフォルダを表す
return PseudoRootFolderId; // "me/skydrive"を擬似的にルートフォルダのIDとして利用する
}
int pos = path.LastIndexOf ('/');
string name = path.Substring (pos + 1);
string parent = path.Substring (0, pos);
if (parent.Length == 0)
parent = "/";
string parentId = Resolve (parent);
if (name.Length == 0)
return parentId;
var e = GetCacheEntry (path); // pathに対応するキャッシュエントリを取得
if (e == null) {
updateCache (parent, parentId); // キャッシュにないのでOneDriveに問い合わせる
e = GetCacheEntry (path);
if (e == null)
return null;
}
return e.Entry.ID; // pathに対応するIDを返却
}
フォルダの作成方法
http://msdn.microsoft.com/ja-jp/library/dn659743.aspx#create_a_folder に書いてあるとおり.
親フォルダのIDを使ってPOSTするだけ.成功すると201が帰ってくる
既にフォルダがある場合や,親フォルダIDが存在しない場合は400が帰ってくるので区別できない.
てきとーに,以下のような感じ.
bool CreateDirectory (string parentFolderId, string name)
{
string uri = LiveBaseUri + parentFolderId;
HttpWebRequest req = (HttpWebRequest)WebRequest.Create (uri);
req.Method = "POST";
req.ContentType = "application/json";
req.Headers.Add ("Authorization", "Bearer " + AccessToken);
using (var strm = req.GetRequestStream ()) {
byte[] raw = Encoding.UTF8.GetBytes ("{\"name\": \"" + name + "\"}");
strm.Write (raw, 0, raw.Length);
}
try {
using (HttpWebResponse res = (HttpWebResponse)req.GetResponse ()) {
if (res.StatusCode == HttpStatusCode.Created)
return true;
}
} catch {}
return false;
}
レスポンスボディにIDとか情報が入っているのでキャッシュ更新に使えるんだけど,
res.GetResponseStreamとしてStreamReaderでReadToEndしても正しいJSONが読み込めないので,
レスポンスボディは利用していない.
その代わりレスポンスヘッダのLocationフィールドにフォルダIDを含むURIが入っているのでそれを利用してキャッシュを更新している.
ファイル・フォルダの削除方法
http://msdn.microsoft.com/ja-jp/library/dn659743.aspx#delete_a_file に書いてあるとおり.
削除したいフォルダ・ファイルのIDを指定してDELETEメソッドで送るだけ.
成功すると204が帰ってくる.指定したIDを持つファイル・フォルダがないときは400が帰ってくる.
bool Delete (string id)
{
string uri = LiveBaseUri + id + "?access_token=" + AccessTokenEscaped;
HttpWebRequest req = (HttpWebRequest)WebRequest.Create (uri);
req.Method = "DELETE";
try {
using (HttpWebResponse res = (HttpWebResponse)req.GetResponse ()) {
if (res.StatusCode == HttpStatusCode.NoContent)
return true;
}
} catch {}
return false;
}
ファイルのダウンロード方法
http://msdn.microsoft.com/ja-jp/library/dn659726.aspx#download_a_file にも書いてあるとおり,
https://apis.live.net/v5.0/file.0123456789/content にGETするだけでとても簡単.
ただRange指定出来ない…?
コピペが面倒になってきたのでコードはGitHubの方を...
ファイルのアップロード方法
http://msdn.microsoft.com/ja-jp/library/dn659726.aspx#upload_a_file に書いてあるとおり.
POSTだとマルチパートで面倒なので今回はPUTを使いました.
僕の環境では1コネクションあたり120Mbpsぐらいでアップロードできたので結構早いかも?
Content-Typeに"application/octet-stream"を指定すると対応していないメディアタイプだよって怒られるので何も指定しない.
1ファイルあたり100MBの容量制限がある.
また,こちらもフォルダの作成と同様にレスポンスボディにJSONが入っているはずなのだが,上手く読み込めないので
レスポンスヘッダのLocationフィールドにフォルダIDを含むURIが入っているのでそれを利用してキャッシュを更新している.
FUSE (Mono-fuse)
Mono-Fuseは,LinuxのFUSEの.NETバインディングです.
Mono.Fuse.FileSystemクラスを継承するクラスを作成し,以下のようにすることで任意のディレクトリにマウントします.
using (Fuse fuse = new Fuse (null)) { // FuseクラスはFileSystemの派生クラス
args = fuse.ParseFuseArguments (args);
fuse.MountPoint = "/mnt/fuse";
fuse.Start();
}
ファイル・ディレクトリツリーの表示
FileSystemクラスには沢山オーバーライド可能なメソッドが定義されていますが,
ディレクトリの一覧を表示するためには次のメソッドをオーバーライドすればOKです.
- OnGetPathStatus
- OnReadDirectory
OnGetPathStatusでは,stat(2)の様に指定されたパスの情報を返却し,
OnReadDirectoryでは"."や".."を含む,ファイル・フォルダ名の一覧を返却します.
OnGetPathStatusではstat(2)の構造体の説明を参考に各フィールドを設定します.といっても,st_mode, st_nlink, st_sizeぐらいでしょうか.
フォルダの作成・削除/ファイルの削除
フォルダの作成・削除・ファイルの削除は次のメソッドをオーバーライドすればOKです.
- OnCreateDirectory
- OnRemoveDirectory
- OnRemoveFile
引数にパスが渡ってくるのでそのパスを削除する処理を行い,0を戻します.
ファイルの読込
ファイルの読み込みは次のメソッドをオーバーライドします.
- OnOpenHandle
- OnReadHandle
- OnReleaseHandle
全てのメソッドの引数にファイルパスが渡ってきますが,ファイルパスではファイルデスクリプタを区別することは出来ませんので,引数で渡されるOpenedPathInfoのHandle(IntPtr)に識別子を保存することで状態を保持します.IntPtr型なので実際はint型等のキーを保存しておき,実際の状態はDictionary等に保存しておき,OnOpenHandleで状態の登録,OnReadHandleでは状態の取得,OnReleaseHandleで状態を削除するということをします.
protected override Errno OnOpenHandle (string file, OpenedPathInfo info)
{
Console.WriteLine ("OnOpenHandle: {0}", file);
if (!info.OpenAccess.HasFlag (OpenFlags.O_RDONLY)) // Read-Only以外はエラーにする
return Errno.EACCES;
Stream strm = _fs.ReadOpen (file);
if (strm == null)
return Errno.ENOENT;
int handle = Interlocked.Increment (ref _handleIndex); // 一意なキーを生成
_lock.EnterWriteLock();
_handles.Add (handle, strm);
_lock.ExitWriteLock();
info.Handle = new IntPtr (handle); // IntPtr型のHandleにキーを保存
return 0;
}
protected override Errno OnReadHandle (string file, OpenedPathInfo info, byte[] buf, long offset, out int bytesWritten)
{
Console.WriteLine ("OnReadHandle: {0} off={1}", file, offset);
Stream strm;
bytesWritten = -1;
_lock.EnterReadLock();
object obj;
if (!_handles.TryGetValue (info.Handle.ToInt32 (), out obj)) { // handleより状態を取得
_lock.ExitReadLock();
return Errno.EIO;
}
_lock.ExitReadLock();
strm = obj as Stream;
if (strm == null || strm.Position != offset) // シークには対応していない...
return Errno.EIO;
bytesWritten = strm.Read (buf, 0, buf.Length);
return 0;
}
protected override Errno OnReleaseHandle (string file, OpenedPathInfo info)
{
object obj;
_lock.EnterWriteLock();
if (!_handles.TryGetValue (info.Handle.ToInt32 (), out obj)) {
_lock.ExitWriteLock();
return Errno.EINVAL;
}
_handles.Remove (info.Handle.ToInt32()); // 状態を削除
_lock.ExitWriteLock();
Stream strm = obj as Stream;
if (strm != null) {
strm.Close ();
return 0;
}
return Errno.EIO;
}
ファイルの新規作成・書き込み
ファイルの新規作成および書き込みには次のメソッドをオーバーライドします.
- OnCreateHandle
- OnWriteHandle
- OnReleaseHandle
OnReleaseHandleは読み込み・書き込み時両方から呼び出されるので,ハンドルと紐付いている状態を見て,読み込み用・書き込み用のクローズ処理を行うようにします.
それぞれのメソッドの内容はほとんどファイルの読み込み時と同じです.OnCreateHandleでファイルを作成し,OnWriteHandleでは書き込むデータが渡ってくるので,指定されたオフセットに書き込む処理を記述します.OnReleaseHandleではクローズ処理を行います.