Angular と nestjs で IPFS にファイルをアップロードするアプリケーションを作ろう

これは ビットバンク株式会社 Advent Calendar 2020 の 3 日目の記事です。

はじめに

ビットバンクでアプリケーション開発をしているおくなもです。
弊社では IPFS は使ってないんですが、前から個人的に面白そうだなと思っていたので今回取り上げてみました。

IPFS とは

IPFS は "InterPlanetary File System"の略です。
直訳すると"惑星間ファイルシステム"という壮大な名前ですが、一言で言えばコンテンツ指向のファイルシステムです。

IPFS はただのファイルシステムの枠を超えて異なるアーキテクチャの新しい WEB として注目を集めています。

詳しいことは公式のドキュメントに譲りますが、主な特徴は次の3点です。

  1. コンテンツ指向であること。URI を使用せず、コンテンツ識別子(CID ≒ コンテンツのハッシュ値)をアドレスとして用いる

  2. クライアントが P2P ノードを立て、ノードどうしでキャッシュとしてファイルを共有する分散型のアーキテクチャであること

  3. 既存の WEB と共存できること

特に自分が魅力に感じるのは2です。
この特徴は「人気がある情報ほど滅びやすい」という WEB のジレンマの解消に繋がるものであるためです。

しばらく前ですが、持続化給付金の応募サイトやマスクの通販サイトにアクセスが集中しすぎてサーバに接続できなくなったことがニュースになっていました。
あまりにも当たり前の話で違和感を持ちにくいですが、WEB では人気がある情報にアクセスが集中するとサーバーや回線の負荷が高くなって接続できなくなったりサーバーがダウンしたりしますよね。

ちょっとモダンなアーキテクチャを採用していたらどうでしょう。CDN を使用して静的サイトをキャッシュし、API へのアクセスにはロードバランサーを使用してサーバーを複数同時起動してアクセスの増加に対応する、みたいな感じでしょうか。しかし、結局「アクセス増加」を「コスト増加」にすり替えただけで本質はそれほど変わっていません。

人気がある情報のほうが情報の公開コストが上がるので(広告や有料サービスなどでコストを取り返さない限り)いつかは公開者はコストを負担しきれなくなります。つまり滅びやすいと言えます。

しかし IPFS のアーキテクチャであれば、人気がある情報はより多くのノードにキャッシュされることになります。
新しいノードがその情報を取得したくなったら、他の複数のノードから分割したファイルを別々にダウンロードすることができるので、高速にダウンロードでき、かつ一つのノードにトラフィックが集中することはありません。

そして、情報をキャッシュしているノードが一つでも生き残っていれば、誰かがその情報を欲したときにそれを手に入れることができます。
つまり人気がある情報のほうが生き残りやすいのです。

余談ですが、WEB 以外の旧来の物理プラットフォームであれば、「人気がある情報のほうが生き残りやすい」のが普通です。
例えば本を例にすると、平安時代の「源氏物語」という小説は非常に人気が高かったため、いろんな人に写本され現在まで受け継がれています。現代でも人気がある本は物体として大量に生産され配布されるため、人気が高いほど生き残りやすいと言えます。ただし、源氏物語のコピー方法はアナログだったったため、途中で書き間違えたりして内容が変わってしまうことがあったそうです。
IPFS のようにコンテンツ指向であれば、内容が変われば CID も変わるため、内容が途中で改変されてしまうこともなかったでしょう。

何に使われているか

本題ではないんですが、実用例について軽く触れておきます。

IPFS Ecosystem(https://ipfs.io/ より引用)
1-1

IPFS pin service とは

さて、IPFS 上でファイルを保存するためには特定のノードがそのファイルを固定(これを"pin"と呼びます)する必要があります。
pin されないデータも一時的にノード上にキャッシュとして存在することはできますが、ノードがガベージコレクションをトリガしたときに消されてしまいます。

では IPFS でホスティングしている WEB3 系 のアプリケーションたちはどうやってファイルを保持しているのか? それには(なかの人ではないので)おそらくですが2つ方法が考えられます。
一つは自分たちで IPFS のノードを管理して自分で pin する方法。
もう一つは"IPFS Pinning services"を利用する方法です。

Persistence, permanence and pinning

リンク先に説明があるのでここでは大雑把にまとめると、お金を払う代わりにファイルを pin してくれるのが Pinning services です。
今回は、支払い機能を作るのは大変なのでそれは除外して、簡単な Pinning services を作ってみます。

構成

アプリケーションの構成としてはクライアントからサーバにファールを送信し、サーバ側で同じサーバ上の IPFS node にファイルを追加することを想定しています。

2-1

ただし今回はプロトタイプなので、全てローカルで動かします。つまりこういう構成になります。

3-1

環境

node: v12.16.3
npm: v6.14.5
yarn: v1.22.4
angular/cli: 9.1.9
nestjs/cli": 7.4.1
js-ipfs: v0.47.0

クライアントの実装

フロントエンドアプリケーションの作成

angular/cli でサクッと作成します。
名前は'ipfs-pinning-web'としていますが自分の好きなものを入れてください。

npm install -g @angular/cli
ng new ipfs-pinning-web
cd ipfs-pinning-web

マテリアルボタンを使いたいので Angular Material を入れます。

ng add @angular/material

ローカルサーバーの起動

// yarnの場合
yarn

yarn start

アップロード機能の実装

app.component に以下のように実装しました。
AppModule で MatButtonModule,HttpClientModule を import する必要があります。

マテリアルボタンを使用したかったので、 input 要素は不可視にして onSelectFileButtonClick()から呼び出しています。
レスポンスとしてアップロードしたファイルの CID の値を受け取ることを想定しており、画面にも表示しています。

// app.component.html
<div class="wrapper">
  <h1>{{title | titlecase}}</h1>
  <div class="select-container">
    <button
      mat-stroked-button
      class="button"
      color="primary"
      (click)="onSelectFileButtonClick()"
    >
      ファイルを選択
    </button>
  </div>

  <div class="upload-file-container" *ngIf="fileName">
    <div>{{fileName}}</div>
    <button
      mat-stroked-button
      class="button"
      color="primary"
      (click)="onImageUploadButtonClick()"
    >
      アップロード
    </button>
  </div>

  <div class="response">
    <h2>Response</h2>
    {{response}}
  </div>

  <input
    #file
    (change)="processFile(file)"
    type="file"
    id="file"
    class="file-input"
  />
</div>

HTTP リクエストメソッドは PUT にしてみました。
コンテンツ指向アドレスなため、コンテンツを指定した時点でリソースのアドレスは指定されていることと、ipfs へのファイル追加がべき等とみなせるためです。

// app.component.ts
import { Component, ElementRef } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

interface UploadResponse {
  hash: string;
}

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'ipfs-pinning-web';
  input: HTMLInputElement;
  file: File;
  fileName = '';
  response = '';

  constructor(
    private readonly el: ElementRef,
    private readonly http: HttpClient
  ) {}

  onSelectFileButtonClick(): void {
    // Initialize input.
    if (this.input) {
      this.input.value = '';
    }
    this.fileName = '';

    this.input = this.el.nativeElement.querySelector(`#file`);
    this.input.click();
  }

  onImageUploadButtonClick(): void {
    const formData = new FormData();
    formData.append('file', this.file, this.file.name);

    this.upload(formData).subscribe({
      next: res => {
        this.response = res.hash;
      },
      error: e => {
        console.error(e);
      }
    });
  }

  processFile(input: any): void {
    this.file = input.files[0];
    this.fileName = this.file.name;
  }

  private upload(body: FormData): Observable<UploadResponse> {
    const url = 'http://localhost:3000/ipfs';
    return this.http.put<UploadResponse>(url, body);
  }
}

バックエンドアプリケーションの作成

nestjs/cli でサクッと作ってしまいます。
アプリケーション名は'ipfs-pinning-front'としていますが、こちらも自由に変更してください。

npm i -g @nestjs/cli
nest new ipfs-pinning-front
cd ipfs-pinning-front

ファイルを扱うため multer の型情報のパッケージを読み込み

yarn add --dev @types/multer

js-ipfs パッケージの読み込み

yarn add ipfs

API の実装

Angular と同じように、最上位の AppController に直接 API を作成してしまいます。
ファイルを受け取るために FileInterceptor でファイルを保持する HTML フォームのフィールド名を指定し、UploadedFile デコレータでファイルをリクエストから抽出しています。

// app.controller.ts
import { Controller, Put, UseInterceptors, UploadedFile } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Put('ipfs')
  @UseInterceptors(FileInterceptor('file'))
  async uploadfile(
    @UploadedFile() file: Express.Multer.File
  ): Promise<{ hash: string }> {
    return await this.appService.upload(file);
  }
}

実際に PUT リクエストを受け取るには main.ts で CORS の設定を追加で行う必要があります。

// main.ts
import { AppModule } from './app.module';
import { IpfsService } from './services/ipfs/ipfs.service';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    cors: {
      origin: true,
      preflightContinue: false
    }
  });
  await app.listen(3000);
}
bootstrap();

IPFS へのアップロード機能の実装

あとは AppService にアップロード機能を実装するだけですが、 その前に ipfs ノードを初期化するサービスを作成しておきましょう。

// ipfs.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { inspect } from 'util';

const IPFS = require('ipfs');

@Injectable()
export class IpfsService {
  private ipfs = null;

  async init(): Promise<void> {
    if (this.ipfs) {
      return;
    }
    Logger.debug('Ipfs service initializing.');

    this.ipfs = await IPFS.create({ silent: true });

    const version = await this.ipfs.version();
    Logger.debug(`Start ipfs node. version: ${inspect(version)}}`);

    Logger.debug('Ipfs service initialized!');
  }

  get(): any {
    return this.ipfs;
  }
}

init では IPFS.create()を呼ぶだけで js-ipfs のノードが立ち上がっており、他の peer との接続が開始されています。めちゃくちゃ簡単ですね。
get メソッドでは ipfs のインスタンスを返しているだけです。ログもいい感じに仕込んでおきました。

IpfsService の初期化を bootstrap に追加したいので main.ts を修正します。

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { IpfsService } from './services/ipfs/ipfs.service';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    cors: {
      origin: true,
      preflightContinue: false
    }
  });

  // Initialize ipfsService.
  const ipfsService = app.get(IpfsService);
  ipfsService.init();

  await app.listen(3000);
}
bootstrap();

作成した IpfsService を AppService で使用するには AppModule にプロバイダーとして登録する必要があります。

// app.module.ts
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { IpfsService } from './services/ipfs/ipfs.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, IpfsService]
})
export class AppModule {}

これで準備ができたので AppService を以下のように実装します。

// app.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { inspect } from 'util';
import { IpfsService } from './services/ipfs/ipfs.service';

@Injectable()
export class AppService {
  constructor(private readonly ipfsService: IpfsService) {}

  async upload(file: Express.Multer.File): Promise<{ hash: string }> {
    Logger.debug(`filename: ${inspect(file.originalname)}`);

    const ipfs = this.ipfsService.get();

    // info
    const peers = await ipfs.swarm.peers();
    Logger.debug(`Ipfs node info.\n peers: ${inspect(peers)}`);

    const path = `/${file.originalname}`;

    // files API aka MFS
    await ipfs.files.write(path, file.buffer, { create: true });
    const status = await ipfs.files.stat(path);

    Logger.debug(`status: ${inspect(status)}`);

    // pin
    await ipfs.pin.add(status.cid);
    Logger.debug(`Pinned a file. CID: ${inspect(status.cid)}`);

    return { hash: String(status.cid) };
  }
}

ipfs のインスタンスを IpfsService から受け取っています。機能的には不要ですが、ノードが立ち上がっている様子がわかりやすいので peer をログに表示してみました。
MFS の write を使用してファイルを追加しています。create オプションを有効にしておくと、ファイルが既に存在した場合は上書きしてくれ、存在しない場合は作成してくれます。

MFS について軽く説明しておくと、ipfs のファイル操作を unix のファイル操作ライクにラップしてくれる API です。
そのため MFS には ディレクトリや path の概念が存在し、path で指定したファイルに対して、ls, cp, mv, mkdir, rm などのおなじみコマンドのような API を提供してくれます。
当然 MFS を使用しなくても ipfs.add のような API でファイル追加を行うことも可能です。

MFS でファイルを追加したあと、stat API で取得した CID を使用してipfs.pin.add(status.cid)のようにファイルを pin しています。MFS で追加したファイルはデフォルトでベストエフォートポリシーによって保持されていますが、ちゃんと pin したい場合にはファイルの CID を指定して pin する必要があるためです。

最後に stat API から取得した CID の値を文字列としてフロントに返しています。

完成したので動かしてみる

フロントとバックエンドのアプリケーションを立ち上げます。
nestjs も angular も同じコマンドです。

yarn start

nestjs の起動ログから bootstrap で ipfs ノードが立ち上がっているのがわかります。

4-1

適当な画像をアップロードしてみましょう。注意点ですが、IPFS へのアップロードは WEB 上へのアップロードと同義なので権利やプライバシー的に問題があるファイルをアップロードしてはいけません。
また事後的にも自分のノードからは削除できますが、他の人が保存してしまったらファイルを削除することは難しくなります。
今回はいらすとやさんの画像をお借りしました。

5-1

ローカルに立ち上げた Ipfs-pinning-web から画像をアップロードします。
即座にアップロードが完了して、CID の文字列がレスポンスとして返ってきます。

6

ipfs-pinning-front のログを見る限り ipfs へのアップロードと pin にも成功しているようです。

7

それでは、ipfs.io のゲートウェイからファイルが取得できるか確認してみましょう。
やり方は簡単で、先程フロントエンドに返ってきた hash 値を以下の URL に入れてブラウザでアクセスするだけです。

https://ipfs.io/ipfs/{hash}

8

表示できました。
ipfs.io のゲートウェイが IPFS のネットワークから私が今立ち上げたノードを見つけ、ファイルを要求し、ノードがそれをゲートウェイに送信し、ゲートウェイはそれをブラウザーに送信しています。

ちなみに ipfs.io のゲートウェイはボランティアで運営されているので永遠に使用できるかはわかりません。
もし接続できなかったり調子が悪ければ、自分でローカルにゲートウェイを立ち上げてファイルを取得することもできます。

こちらのドキュメントにクイックスタートがあるので、ぜひ試してみてください。
docs.ipfs.io/install/command-line-quick-start

参考:
Files API
Pin API

結び

今回はプロトタイプ的な実装でしたが、本格的な IPFS pin service としてはユーザの認証だったり入金機能などが必要そうです。
MFS の API は IPFS の仕組みを隠蔽してくれていて unixlike な操作性で使いやすかったです。今回はファイルの追加だけでしたが複雑な操作も簡単に実装できそうでした。

今回は詳しく触れませんでしたが、他にも Web console という GUI ツールが開発されていたり、IPFS Companion というブラウザ拡張でローカルノードを操作できる開発ツールができていたり、気軽に IPFS に触れるツールも増えているようです。
開発はちょっと、という方もそこら辺を触ってみると楽しいと思います。

Author image
About ocknamo
expand_less