Help us understand the problem. What is going on with this article?

NEMでファイル共有システムを作ってみたらこうなった

(この記事はNEMアドベントカレンダー2018の7日目の記事です)

追記

この記事、長々と書いていますが伝えたいことは、NEMは1024文字をメッセージ上に記述することができるので構造を持ったテキストを書き込めばなんでも書き込めるということです。

以下本文

こんにちは、@scrpgilです。
本記事はNEMでファイル共有システムを作ってみたらどうなるか?と思い実際に作ってみた記事です。

みんな一度は「サーバーいらない」「使い放題の分散型ストレージ」といったイメージをブロックチェーンに持つんじゃないでしょうか?1
少しブロックチェーンに触れるとそういったことを実現するのは意外と簡単ではないと感じると思います2

今回、自分の中にあった実際にオンチェーンでシステムを作ってみたらやっぱ難しいのかな?という疑問を解決すべくファイル共有システムを作ってみた感じです。

この記事を読んで「意外といけるやん!俺も何か作ってみる!」とか「もっと良いやり方ある」とか「辛そうww」とか思ってもらえたら幸いです。

ファイル共有システム概要

今回作ったのはNEMチェーン上にファイルを書き込み、ユーザー間で共有できるシステムです。
Screen Shot 2018-12-07 at 2.31.11.png

ファイル共有の手順は以下のとおり。
①共有するファイル用にアドレスを一つ用意
②トランザクションのメッセージにファイルを書き込む
③共有したい相手に①のアドレスを教える
④アドレスからファイルを読み出す

実際に作ったもの

そんなこんなで作ったのが以下のサイト「NEM P2P」です。
※Ionicでできてます。高速で作れて最高です。

アドレス入力欄とFetchというボタンだけがある質素なサイトです。
Screen Shot 2018-12-06 at 15.50.27.png

既にいくつかファイルを保存してあります。以下のURLをクリックしてみてください。
http://p2p.nempass.com/?address=NBCLBDSKQGKAIE6Y5ULWBZVW5S332TC4BMIGXPNG

Fetchボタンを押すと画像ファイルが表示されます。
これが、チェーン上に書き込まれた画像ファイルになります。
Screen Shot 2018-12-07 at 1.08.02.png

画像以外のデータも保存できます。例えば以下のアドレスはmp3ファイルが保存されています3
http://p2p.nempass.com/?address=NC7I2S6K5HZCN6SDMC2IBKHGAKXJAHIF2VCMEMP3

Screen Shot 2018-12-07 at 1.07.27.png

基本的にファイルは平文で保存されていますが、NEMのメッセージ暗号化の仕組みを使って暗号化することもできます。
例えば、以下のファイルはアドレスのみでは読み込みできず、秘密鍵の入力が必要になります。
http://p2p.nempass.com/?address=NDXUP2K5JRPAYM3FYNBZWUOT2QEKF3CLWWBL4PNG
秘密鍵は「e95ed66c34aa0048ca46f934a9a64f4258bddd9746efcabc1c89e5a72711337e」です
Screen Shot 2018-12-07 at 1.30.40.png

以上、ざっくりした紹介ですがどんなシステムか解っていただけたでしょうか?

保存の仕組み

ファイルをどうやってNEMチェーン上に保存するか?
簡単な方法はファイルをBase64エンコードしてメッセージに書き込むことだと思います。
ただし、そのままでは1メッセージ=1024文字という制限に引っかかります。

そこで、Base64の文字列を1メッセージに収まるよう分割して書き込むんでます。
図にすると下のような感じ。
Screen Shot 2018-12-07 at 1.52.57.png

アドレス毎に書き込んでいるのはファイルの管理&共有がしやすいからです。
例えば、各Base64が書き込まれてるJSONにMetaDataが書き込まれたトランザクションのハッシュの情報も持たせれば一つのアドレスで複数のファイルを管理することも可能です。
しかしながら、この方法だとMetaDataの書き込みを待ってHashの情報をとらないとBase64のメッセージを作成できない、ファイル毎にアクセスしずらい等いろいろ('A`)マンドクセなので今回はやめました。

あと今回作ったシステムは技術的に難しいところはありません。これくらいのシステムを作るのに必要なAPIは全部用意されているのがNEMの良いところだと思います。

現実的にファイル共有システムとして使うとどうよ?

まず、非常にコストが高くつきます。なにせ80kBのファイルを保存するのに約190XEMですので、現在のXEMの価格(1XEM = 8.5円4)で考えると約1600円必要になります。数メガバイトのmp3ファイルだと数十万円かかります。

一度書き込まれたデータは消えない&第三者の書き込みを防ぐことはできないのでサービスで利用する際の法的リスクも気になるところです。
著作権に問題のあるファイルが書き込まれた場合、責任の所在はどうなるのだろうか・・・?

メリットをいうと
・本当にサーバーがいらない
・絶対に消えない&改ざんできない
・一度書き込めば読み出しコストは低い
という点が考えられます。
実際にFetchボタンを押してもらった方ならわかると思いますが、それなりな速度でファイルを読み取れますし。

書き込みコストが高く、読み出しコストが低いのは今のS3やGCS等と逆の印象ですね。

現実的にシステムを組むなら・・・

ここは大人しくプライベートブロックチェーンの「mijin」を使いましょう。NEMと同じAPIが使えるプライベートブロックチェーンなので、上記にある「書き込みコスト高」、「法的リスク」に対処することが容易になるんじゃないかと思います。5

まとめ

何はともあれブロックチェーンにファイルを書き込むのはとてもエキサイティングな体験でした。
Pastoraleがブロックチェーンに書き込まれた世界初の音楽だったら嬉しいですね6

本記事を読んで「NEM触ってみたい!」と思った方、簡単に使えてライブラリも豊富、ノードもそれなりに数があって日本語情報が多いNEMを是非とも触ってみてください!

あと、「ファイル書き込みたいけどやり方わからない」と言う方、連絡くれればやり方お伝えします(またはやります)。Twitter等でDMくれればと思います。
https://mobile.twitter.com/scrpgil

それではみなさん素敵なNEMライフを!

ソースコード

https://github.com/scrpgil/nem-p2p
※気に入ったらスターください
※Ionicで出来てます。Ionicは私が超押したいフレームワークです。

実装の説明(追記)

いいね数が10を超えたのでせっかくだから実装内容の説明を追記しました。
かなりざっくり説明ですが、全体的な処理の雰囲気を掴めてもらえれば幸いです。

NEM P2Pはファイル読み出し以外にファイル書き込みを補助する機能もあります。
http://p2p.nempass.com/?mode=testnet

サイト下部にある「Go To Convert」をクリックしてください。
画面が変わって”Choose File"、"Convert"という二つのボタンが表示されます。
試しに、"Choose File"ボタンをクリックして画像を読み込んで"Convert"ボタンを押してみてください。

変換に成功するとFileのMetaDataとBase64エンコードされた文字列が表示されます。これがトランザクションに書き込んである内容になります。

以下、これらの実装について説明します。実装に興味ある方は読んでみてください。

※下準備としてIonicがインストールされている環境だと望ましいです。
Ionicのインストール方法はこちら

ファイル書き込み

ファイル書き込みの手順は以下の通りです。

  1. ファイルをアップロード
  2. Base64エンコードする
  3. 950文字ずつで分割する
  4. ファイル共有用のアドレスに書き込む

1. ファイルをアップロード

まず、ユーザーからファイルを受け取る方法ですが、ここは普通にinputタグとFile APIで作成しています。
アップロードしたファイルは一旦クラス変数のfileToUploadに代入しています。

src/pages/home/home.html
<input type="file" id="file" (change)="handleFileInput($event.target.files)" /><
src/pages/home/home.ts
  handleFileInput(files: FileList) {
    this.fileToUpload = files.item(0);
  }

2. Base64へのエンコード

Convertボタンを押した際、アップロードされたファイルをBase64にエンコードする必要があります。
ここもFileAPIを使っています。

src/pages/home/home.ts
  async convert() {
    const fr = new FileReader();
    fr.onload = (evt: any) => {
      console.log(evt.target.result); // Base64エンコードされたファイルが入ってる
      // ここにBase64→トランザクションにする処理を書く
    };
    fr.readAsDataURL(this.fileToUpload);
  }

3. 950文字ずつで分割する

NEMのトランザクションに書き込めるメッセージの上限は1024文字です。なので1トランザクションにBaes64の全文字列を書き込むことはできません。
ここでは、Base64を950文字ずつに分割して書き込みます。

処理はこちらの記事を参考にしました。
JavaScriptで、文字列を、指定した文字数で分割して、配列で返す

4. ファイル共有用のアドレスに書き込む

最後にConvertで生成されたトランザクションを共有用のアドレスに書き込みます。
書き込むJSONはそれぞれ以下のような感じ。

metadata
{
  "v": "0.0.1",
  "name": "nem-aa.png",
  "type": "image/png",
  "length": 2,
  "size": 1106,
  "lastModified": 1543763189417
}
base64
{
  "id": 0,
  "b": "data:image/png;base64,以下略"
}

書き込みにはNanoWallet等を使ってもらえればOKです。

また、Testnet環境だと書き込み用のボタンがあるので秘密鍵を活用してみてください。
MetaData以外のBase64の文字列を一気に書き込んでくれます。
http://p2p.nempass.com/?mode=testnet

Screen Shot 2018-12-08 at 1.04.29.png

本番環境でも使いたい場合は以下の部分の*ngIf="testnet"を削除すればしてionic serveとかしてくれればOKです(ただし、使用は自己責任で)
https://github.com/scrpgil/nem-p2p/blob/bbf255ca6245817d51f728254b36775b083bae2f/src/pages/home/home.html#L67

ファイル読み出し

ファイル読み出しの手順は以下の通りです。

  1. 指定アドレスのトランザクションを全件取得する
  2. 書き込み日時が古い順にMetaDataが書かれているトランザクションを探す
  3. MetaDataに記載のLength分だけBase64情報を探して結合して一つの文字列にする

以下、これらの処理を実装を紹介します。

1. 指定アドレスのトランザクションを全件取得する

まずは、ファイル共有用のアドレスから全トランザクションを読み出します。
読み出しはnem-libraryを使えば一瞬です。今回は以下のような関数を用意しました。
getAllTransactions()にNEMのアドレスを渡せば全件読み出して返却します。
この関数では最終的にマルチシグからの書き込み、TransferTransaciton以外のトランザクションは除外しています。
また、共有するファイルは先に書き込まれたトランザクション優先としているため、最後に古いトランザクションから順になるよう並べ替えをしています(新しい順だと第三者に書き込まれたデータを優先しちゃうため)

src/providers/nem/nem.ts
  async getAllTransactions(address: string) {
    try {
      let transactions = [];
      let loop = true;
      while (loop) {
        let hash = '';
        if (transactions.length > 0) {
          hash = transactions[transactions.length - 1].transactionInfo.hash.data;
        }
        const res = await this.allTransactions(address, hash).toPromise();
        if (res && res.length > 0) {
          transactions = transactions.concat(res);
        } else {
          loop = false;
        }
      }
      transactions = <TransferTransaction[]>transactions.filter(x => x.type == TransactionTypes.TRANSFER);
      return transactions.reverse();
    } catch (e) {
      return null;
    }
  }
  private allTransactions(address: string, hash: string = '') {
    const accountHttp = new AccountHttp();
    return accountHttp.allTransactions(new Address(address), { hash: hash, pageSize: 100 });
  }

2. MetaDataが書かれているトランザクションを探す

まずは、共有するファイルのMetaDataが書かれたトランザクションを探します。
GetMetaData関数に先ほど取得したtransactions一覧を渡しています。

この関数は以下のような処理をしています、。
1. メッセージの複合
2. 文字列がJSONかどうかチェック
3. JSONならMetaDataオブジェクトかチェック
4. MetaDataオブジェクトならMetaDataとして返却

src/providers/nem/nem.ts
  getMetaData(transaction, privKey: string = '') {
    try {
      for (const t of transaction) {
        const msg = this.decodeMessage(t, privKey); // 1
        if (msg !== '' && Util.isJson(msg)) { // 2
          const obj = JSON.parse(msg);
          const metaData = new MetaData(obj);
          if (metaData.valid()) { // 3
            return metaData; //4
          }
        }
      }
    } catch (e) {
      console.log(e);
    }
    return null;
  }

3. MetaData記載のLength分だけBase64情報を探す

MetaDataを取得したら次は実際にBase64の複合処理になります。
GetMetaData関数にTransactions一覧とMetaDataオブジェクトを渡しています。

この関数は以下のような処理をしています、。
1. MetaDataのLength分のサイズを持った配列変数(Base64Array)を用意
2. メッセージの複合
3. 文字列がJSONかどうかチェック
4. JSONならbinaryオブジェクトかチェック
5. binaryオブジェクトならBase64Array配列のIDの場所にBase64の文字列を格納。すでに文字列が書かれている場合は無視
6. Base64ArrayをJoinして一つの文字列にして返す

src/providers/nem/nem.ts
  mergeBinaryToBase64(transaction, meta, privKey: string = ''): string {
    let binaries: Binary[] = [];
    const base64: string[] = new Array(meta.length); // 1
    try {
      for (const t of transaction) {
        const msg = this.decodeMessage(t, privKey); // 2
        if (msg !== '' && Util.isJson(msg)) { // 3
          const obj = JSON.parse(msg);
          const binary = new Binary(obj);
          if (binary.valid()) { // 4 
            binaries.push(binary);
            if (0 <= binary.id && binary.id < meta.length) {
              if (!base64[binary.id]) { // 5
                base64[binary.id] = binary.b; 
              }
            }
          }
        }
      }
      return base64.join(''); // 6
    } catch (e) {
      console.log(e);
    }
    return null;
  }

追記のまとめ

以上でファイルの書き込み&読み込み処理の説明は終わりです。かなりざっくりした説明ですが、全体の処理の流れを掴めてもらえたでしょうか?

みてもらった通り、かなり単純な設計になってます。それでもブロックチェーンの絶対に消えない&改ざんできないという性質のおかげでセキュリティ面で相当楽ができています。
特に、正しいファイルかどうかはトランザクションの先着順だけで検証が済むのはブロックチェーンならではって感じです。

改めて、本記事を読んで「NEM触ってみたい!」と思ってもらえれば幸いです。
それではみなさん素敵なNEMライフを!


  1. そんなことなかったらすいません 

  2. たぶん 

  3. mp3の曲はツィポーリのpastoraleと言う曲を演奏してmidiにしてmp3に変換したものです。 

  4. 記事執筆時点の価格5: 特に調べてないけどだれかやってそう 

  5. ただし、mijinそのものに1ノード5万円のライセンス料がかかる 

  6. 特に調べてないけどだれかやってそう 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away