term タグ別の記事一覧

GW引き篭もりチャレンジ:React+Electronでアプリを作ってみた総括

 5連休も最終日となりましたが、あっという間でしたね。ということで本日は5日間分のReactアプリ作成レポートをまとめておりました。普段の仕事と変わらないことをやっていたような気もしますが、何もできない連休だったので仕方がないとも言えます。

 Reactのフル機能に触ったわけではないですが、さすがによくできているなと感じました。GUIアプリケーションの構築においてはコンポーネントごとにクラス化するというのが定番のアプローチなのですが、それをうまくJavaScriptの流儀に落とし込めていると思います。開発環境が簡単に構築でき、またコマンド1つで最終的なHTMLやminifyされたJavaScriptコードを出力できるのも便利でした。ただ、そのバックエンドはブラックボックス化されている(Webpackの設定ファイルなどはまったく触れない)ので、ここから外れた使い方をしようとすると面倒なのかもしれません。

 そして、React+Electronの開発が以外に簡単でリリース版パッケージの作成も以外に容易にできるというのはちょっと驚きでした。今回はWebアプリを作ってそこからElectronアプリを作るという流れで作ったために若干無駄な作業が発生してはいますが、最初からElectronアプリを作ることを想定すればもっと迅速な開発ができるのではないかと思いました。

 なお連休は終わりますが、この電子書籍管理アプリについては普通に機能を追加していきたいのでゆったりと開発を続ける予定です。まずはタグやタイトルの管理機能を実装したいですね。

Reactでアプリを作ってみる(5日目) - ダブルクリックでファイルを開く

 本来はアプリ内で電子書籍ファイルを開けると良いのですが、ちゃんとした電子書籍ビューアを作るのは正直大変なので、とりあえずサムネイル画像をダブルクリックすると外部アプリケーションでそのファイルを開くようにしてみます。

Node.jsプログラムからファイルを指定したアプリで開く

 Node.jsではchild_processモジュールのexec()やexec()関数を利用することでファイルを実行できる。ただしmacOS環境では/usr/bin/openコマンドを経由してファイルを開く方が楽である。このコマンドでは、次のように指定することで指定したアプリで指定したファイルを開くことができる。

open <ファイル> -a <アプリの絶対パス>

 また、「-a <アプリの絶対パス>」を指定しないと、そのファイルに紐づけられているデフォルトのアプリでファイルが開く。当然ながらこれはmacOS環境でしか動作しないので、Linux/Windows環境では普通にアプリケーションのバイナリファイルに引数として開きたいファイルを与えて実行すれば良いと思われる

 これを利用して、ebmgr.jsに次のようにopenBook()メソッドを追加した。

function openBook(vpath) {
  // check given path
  const realPath = vpathToRealPath(vpath);
  if (!realPath) {
    return Promise.reject();
  }

  const openCmd = '/usr/bin/open';
  const args = [ realPath ];
  const opts = {};
  const viewer = getViewer(vpath);

  if (viewer && viewer.length) {
    args.push('-a');
    args.push(viewer);
  }

  return new Promise((resolve, reject) => {
    child_process.execFile(openCmd, args, opts,
                           (error, stdout, stderr) => {
                             if (error) {
                               reject(error);
                               return;
                             }
                             resolve();
                           });
  });
};

 getViewer()は電子書籍ファイルの拡張子を元に開くアプリのパスを返す関数で、今回の実行は単にconfigファイルのviewersパラメータを参照して対応する値を返すだけ。

function getViewer(vpath) {
  const ext = path.extname(vpath).toLowerCase();
  const viewers = config.viewers;
  return viewers[ext];
}

 config.jsonではこんな感じに指定している。この場合、ZIP形式ファイルは「/Applications/cooViewer.app」で開かれることになる。

  "viewers": {
    ".zip": "/Applications/cooViewer.app",
    ".cbz": "/Applications/cooViewer.app"
  }

 このメソッドをReactアプリから呼び出せるよう、ipc-client/jsにopenBook()メソッドを追加。

  openBook(path) {
    return this.sendRequest('openBook', path)
      .then(result => {
        return result;
      });
  }

 なお今回Webアプリ版の方はこのメソッドを実装しない。OpenAPIクライアント(openapi-client.js)でこれを実行すると必ず失敗する。

  openBook(path) {
    return Promise.reject("openBook() is not implemented");
  }

Reactでのダブルクリックの扱い

 Webアプリで要素のダブルクリックに対しアクションを取るにはdblclickイベントを使うのだが、ReactではonDoubleClickという属性を定義することでイベントハンドラを定義する。このイベントはThumbnailオブジェクトで発生させたいので、このクラス内にイベントハンドラを記述する。

 まずはrender()で返すHTMLのimgタグ内でこの属性を定義する。

  render() {
    if (this.state.thumbnail) {
      const b64Data = this.state.thumbnail;
      return (
          <div className="Thumbnail">
          <img className="thumbnail" alt={this.props.item.title} src={b64Data}
               onDoubleClick={() => this.onDoubleClickThumbnail()}/>
          </div>
      );
    } else {
      return (
          <div className="Thumbnail">loading...</div>
      );
    }
  }

 ここではイベントハンドラとしてonDoubleClickThumbnail()を実行する無名関数を指定している。onDoubleClickThumbnail()では次のようにclient経由でopenBookメソッドを実行している。

  onDoubleClickThumbnail() {
    const client = getClient();
    client.openBook(this.props.item.path).catch(error => {
      console.log(error);
    });
  }

 これでサムネイルのダブルクリックに反応してその電子書籍ファイルがconfig.json内で指定したアプリケーションで開かれるようになる。また、非Electron環境ではアプリケーションは開かず、コンソールにエラーメッセージが表示される。

環境に応じてElectronアプリのロード元を変更する

 デバッグ時にいちいちコードを変更してElectronアプリ内でのindex.htmlのロード元を変更するのは面倒臭い。そのため、Electronアプリのmain.js内で「EBM_MODE」環境変数を参照し、この値が「development」ならReactの開発サーバーから取得し、そうでなければローカルファイルを直接読み込むように挙動が変わるよう条件分岐を入れた。


  if (process.env.EBM_MODE == 'development') {
    win.loadURL('http://localhost:3333/index.html');
    win.webContents.openDevTools();
  } else {
    win.loadFile('./public/index.html');
  }

 ついでにdevelopmentモードの場合DevToolsを開くようにもしている。

 5日目はここまで。作業時間は1時間半ほど。

Reactでアプリを作ってみる(4日目) - Electronを使ったアプリ化

 サムネイル画像表示ができたので、本日はこのアプリをアプリフレームワークElectronを使ってスタンドアロンで動作するアプリケーション化してみます。

WebアプリをElectronアプリ化する際の要件

 Electronでは、一般的なWebアプリでは実現できないローカルファイルシステムへのアクセスが可能になる。これを利用することで、Webサーバーを使わずにそのアプリ単独で完結するアプリを開発できる。

 ただ、Electron内で表示される画面はあくまでHTMLベースのものなので、画像を表示させたい場合はURLでそのコンテンツの場所を指定する必要がある。今回のサムネイル画像のような動的に生成した画像を表示させたい場合、選択肢としてはelectronのプロセス内でWebサーバーを立ち上げてそこにHTTPでリクエストを投げて画像を取得するか、どこかにファイルとして保存してそのパスをURLとして指定する、もしくはdata:スキーマのURLを使うという3通りがある。今回は別途Webサーバーを立ち上げたくはないので、data:スキーマを利用することにする。

 また、Electron内で実行するコードからはNode.jsの任意のモジュールが利用できるため、電子書籍ファイル一覧の取得やサムネイルの取得はこれらの処理を実装したebmgr.jsを直接importして利用すれば良いだろうと思っていたのだが、今回使用したcreate-react-appベースの開発環境ではfsなどのモジュールが利用できないように細工がされている模様(https://github.com/facebook/create-react-app/issues/3074)。しょうがないので、これらの処理はElectronのメインプロセスで実行することにする。

データアクセスのためのクラス実装

 Electronではアプリ全体を管理するプロセスと画面表示に関連するプロセスが分離されており、前者をメインプロセス、後者をレンダープロセスと呼ぶ。レンダープロセスとメインプロセスはIPCで簡単に通信ができるので、IPC経由でebmgr.jsに実装されている関数を呼び出せるプロクシクラス(ipc-client.js)を実装し、クライアントからはこのプロクシクラス経由で各種関数を呼び出す設計とした。

 なお、IPCを利用するにはelectronモジュールを読み込む必要があるのだが、非Electron環境ではその動作は失敗する。そのため、次のように条件分岐を入れた上でモジュールを読み込ませる必要がある。

let ipcRenderer;
if (window.require) {
  const electron = window.require('electron');
  ipcRenderer = electron.ipcRenderer;
}

 IPC周りのコードは次のように実装した。メインプロセスに対し、「FunctionCall」というメッセージとともにメソッド名、リクエストID、パラメータを送信すると、その処理結果が「<メソッド名>_<リクエストID>」というメッセージとともに非同期に返される、という流れとなっている。返されるメッセージに対するイベントハンドラは、一回限りのハンドラを登録するonce()メソッドで登録している。

let requestId = 0;
const timeoutMilSec = 30000; // 30 seconds

const channelSend = 'FunctionCall';

export default class IpcClient {
  sendRequest(method, params) {
    requestId++;
    return new Promise((resolve, reject) => {
      ipcRenderer.send(channelSend, method, requestId, params);
      const listener = (event, result, error) => {
        if (error) {
          reject(error);
          return;
        }
        resolve(result);
      };
      const channelRecv = `${method}_${requestId}`;
      ipcRenderer.once(channelRecv, listener);
      setTimeout(() => {
        ipcRenderer.removeListener(channelRecv, listener);
        reject({message: "response too late",
                name: 'TimeOutError'});
      }, timeoutMilSec);
    });
  }

  getBooks() {
    return this.sendRequest('getBooks');
  }

  getBookThumbnail(path) {
    return this.sendRequest('getBookThumbnail', path)
      .then(result => {
        result.data = new Blob([result.data]);
        return result;
      });
  }
}

 この場合、何らかの不具合で結果が返ってこないと永遠にそのハンドラが残り続けてメモリリークが発生するので、タイムアウト時間(30秒)を設定してこの時間内に結果が返ってこない場合失敗したとみなしてイベントハンドラを削除するようにしている。

 また、 getBookThumbnail()内で受け取った画像データをBlobに変換しているが、これはswagger-client経由で同じ処理を実行した場合の動作に合わせるために行なっている。

 メインプロセス側のコードは次のようにした。

const channelSend = 'FunctionCall';
ipcMain.on(channelSend, (event, method, requestId, params) => {
  const channelRecv = `${method}_${requestId}`;
  if (method == 'getBooks') {
    ebmgr.getBooks().then(
      result => {
        event.reply(channelRecv, result);
      },
      error => {
        event.reply(channelRecv, undefined, error);
      }
    );
    return;
  } else if (method == 'getBookThumbnail') {
    ebmgr.getBookThumbnail(params).then(
      result => {
        event.reply(channelRecv, result);
      },
      error => {
        event.reply(channelRecv, undefined, error);
      }
    );
    return;
  }
  event.reply(channelRecv, undefined, { message: "method not found",
                                        name: "InvalidMethodError" });
});

 あわせて、Electron環境ではIPC経由、Web環境ではOpenAPI経由で同一の関数にアクセスできるよう、OpenAPI経由での呼び出しについても同じインターフェイスを持つクラス(OaClient)を実装し、Electron環境かどうかをチェックして適切なクラスのインスタンスを返すgetClient()関数をclient.jsというファイルに実装した。

import OaClient from './openapi-client';
import IpcClient from './ipc-client';

export default function getClient() {
  if (isElectron()) {
    return new IpcClient();
  } else {
    return new OaClient();
  }
}

 これで、getClient()経由でクライアントオブジェクトを取得し、それに対しgetBooks()やgetBookThumbnail()を呼び出すことで、Electron/Webブラウザどちらの環境でも同じコードで処理を記述できるようになる。

 electronのメインプロセスのコード(main.js)はドキュメントのサンプルコード(https://www.electronjs.org/docs/tutorial/first-app#electron-development-in-a-nutshell)ほぼそのままだが、IPC関連のコードを追加しているのと、loadFileの部分を次のように変更している。

win.loadURL('http://localhost:3333/');

 こうすることで、index.htmlファイルをReactのテストサーバーから直接取得できるようになり、開発効率が上がる。

画像をdata:URL経由で表示させる

 まずReactアプリのコンポーネントとして、画像を表示するThumbnailコンポーネントを作成する。Reactのコンポーネントはその属性として任意のオブジェクトを受け取れるようになっており、渡されたオブジェクトはconstructor()の引数として渡される。これを引数にsuper()を実行することで、this.propというプロパティでその値にアクセスできるようになる。

class Thumbnail extends Component {
  constructor (props) {
    super(props);
    this.state = { thumbnail: null,
                 };
  }

 ここでは、「item」という属性として電子書籍ファイルの情報(パス、タイトル)を受け取ることを想定し、そのパスを与えてgetBookThumbnail()メソッドを実行することでサムネイル画像を取得する。この処理は非同期で実行されるので、componentDidMount()メソッド内で実行する。画像を取得できたら、FileReaderオブジェクトを使ってそれをdata: URLに変換してstate.thumbnailに格納し、これをsrc属性に指定したimg要素を出力させる。

  componentDidMount() {
    const client = getClient();
    client.getBookThumbnail(this.props.item.path).then(
      result => {
        //console.log(result);
        const reader = new FileReader();
        reader.readAsDataURL(result.data);
        reader.addEventListener('load', event => {
          this.setState({thumbnail: reader.result});
        });
      }
    );
  }

  render() {
    if (this.state.thumbnail) {
      const b64Data = this.state.thumbnail;
      return (
          <div className="Thumbnail">
          <img className="thumbnail" alt={this.props.item.title} src={b64Data} />
          </div>
      );
    } else {
      return (
          <div className="Thumbnail">loading...</div>
      );
    }
  }
}

 ThumbnailGridコンポーネントでは、このThumbnailコンポーネントを利用する形にrender()を修正した。

render() {
  const makeThumb = x => {
    const encodedPath = encodeURIComponent(x.path);
    return (
        <li key={x.title}>
        <Thumbnail item={x} />
        </li>
    );
  };
  const listItems = this.state.items.map(makeThumb);
  return (
      <div className="ThumbnailGrid">
      <ul>{listItems}</ul>
      </div>
  );
}

Electronアプリの起動

 Electronを利用したコードは、electronコマンド経由で実行する必要がある。毎回手で打ち込むのは面倒なので、Makefileを作成した。

run:
	./node_modules/.bin/electron main.js

 これで試しにアプリを実行したところ、サムネイル変換処理が終わるまでアプリが固まることが判明。ghostscriptの実行を同期的に行なっているのが原因と思われる。ということで、ebmgr.js内でexportしている関数はすべてPromiseを返す非同期関数に書き換える。

function getZipThumbnail(vpath) {
  const realPath = vpathToRealPath(vpath);
  const zip = new AdmZip(realPath);
  const rex = /(\.jpeg|\.jpg)$/;

  for (const entry of zip.getEntries()) {
    if (!entry.isDirectory && rex.test(entry.entryName)) {
      console.log(entry.entryName);
      const data = zip.readFile(entry);
      return Promise.resolve({ contentType: 'image/jpeg',
                               data: data });
    }
  }
  return Promise.reject();
}

 ZIPファイルの処理をするAdmZipは非同期処理をサポートしていないようなので単にPromiseでラップした結果を返すだけ。PDFの処理をしているghostscript4jsは非同期に処理を実行するexecuteメソッドを提供しているので、それをPromiseでラップしている。

  const gsCmd = [ "-sDEVICE=jpeg",
                  "-o",
                  destination,
                  "-sDEVICE=jpeg",
                  "-r72",
                  `-dFirstPage=${page}`,
                  `-dLastPage=${page}`,
                   "-q",
                  realPath];

  return new Promise((resolve, reject) => {
    gs.execute(gsCmd, err => {
      if (err) {
        reject(err);
        return;
      }
      resolve(destination);
    });
  });

 getPdfThumbnail()はエラー処理をサボっているので、async funtion化でPromiseを返すようにした。Node.jsではfs.promises以下にPromiseを返すメソッドが用意されているのでこれを活用している。

async function getPdfThumbnail(vpath, page) {
  const pathname = await makeTempFile(vpath, ".jpeg");
  await generatePdfThumbnail(vpath, pathname, page);

  const data = await fs.promises.readFile(pathname);
  await fs.promises.unlink(pathname);

  return { contentType: 'image/jpeg',
           data: data };
}

 これらに対応して、サーバー側でこれらを呼び出しているコードも書き換えておく。

 さて、相変わらず処理は重いものの、これでアプリがサムネイルのロード環境まで固まる現象は解決。ただ、すべての画像が一斉に表示されるという別の問題に気付く。とはいえ現時点ではクリティカルな問題ではないのでToDOに入れて一旦放置。

スタンドアロンなElectronアプリ化

 この状態では、Electronアプリ内で表示するHTMLファイルはAPIサーバー経由で取得しているので、これを静的なHTMLに切り替えることでアプリがスタンドアロンで動作するように変更する。まず、create-react-appでビルドしたアセットディレクトリ(build)のシンボリックリンクをelectronディレクトリ内に「public」という名前で作成し、この中のindex.htmlをElectronアプリ側のmain.js内で次のようにロードさせてみる

  win.loadFile('./public/index.html');

 すると、動かない。これは、create-react-appで作成した環境でビルドして出力されたindex.htmlファイルではJSやCSS、画像などの各種リソースがすべて「/」から始まる絶対パスで書かれているため。ググったところ、これはReactアプリのpackage.jsonに「“homepage”: “."」を追加することで解決できるとあったので(https://github.com/facebook/create-react-app/blob/1d03579f518d2d5dfd3e5678184dd4a7d8544774/docusaurus/docs/deployment.md)、これを試したところ無事解決。

 これでWebサーバーレスでアプリを実行できるようになったので、これらをアプリとしてパッケージングしてみる。基本的にはドキュメント(https://www.electronjs.org/docs/tutorial/application-distribution)で書かれている通りの作業をすることになる。

 これは定期的に実行することになるので、Makefileを書く。

APP_DEST=release
ELECTRON_PREBUILT=electron/electron/dist
ELECTRON_DIR=electron
RES_DIR=$(APP_DEST)/Electron.app/Contents/Resources/app
REACT_DIR=react-app

dist: $(APP_DEST)
        rsync -a $(ELECTRON_PREBUILT)/ $(APP_DEST)
        rsync -a --exclude='*~' \
                 --exclude='ebmgr.js' \
                 --exclude='Makefile' \
                 --exclude='electron' \
                 --exclude='public' \
                 $(ELECTRON_DIR)/ $(RES_DIR)/
        cp -a ebmgr.js config.json $(RES_DIR)/
        rsync -a $(REACT_DIR)/build/ $(RES_DIR)/public/

$(APP_DEST):
        mkdir -p $(APP_DEST)

 また、Electronアプリ内でghostscript4jsやadm-zipモジュールを利用できるよう、package.jsonファイルに依存性を追加しておく。

  "dependencies": {
    "adm-zip": "^0.4.14",
    "electron": "^8.2.5",
    "ghostscript4js": "^3.2.1"
  },

 これでElectronのアイコンをダブルクリックすると起動するアプリ化完了。

 デバッグ中なのでDevToolsが表示されているが、これはElectronアプリのmain.js内の次のコードを削除すれば表示されなくなる。

  win.webContents.openDevTools();

 本日はここまで。作業時間はちょっと使ってしまって5時間ほど。

Reactでアプリを作ってみる(3日目) - コンテンツの動的な表示

 GWなのでReactで電子書籍管理アプリを作ってみようという話の3日目です。

PDFからのサムネイル画像の作成と読み込み

 2日目の課題として残っていたサムネイル画像をどうやって扱うか問題だが、とりあえずテンポラリディレクトリ上に一時的にサムネイル画像ファイルを生成し、その直後にそのファイルを読み込んだ後削除することで、ローカルファイルシステム上にサムネイルを保持させない方針にしてみる。この時ファイル名に気をつけないと、実行のタイミングによっては一時的に作った画像ファイル名が衝突して意図しないものにすり替わるというトラブルが発生する可能性があるため、作成する画像ファイル名は電子書籍ファイルのパス名のハッシュを用いて生成するようにする。

function makeTempFile(vpath, ext) {
  const tmpdir = os.tmpdir();
  let pathname = path.join(tmpdir, getHash(vpath, 'sha256') + ext);

  let done = false;
  let n = 0;
  while (!done) {
    try {
      fs.writeFileSync(pathname, "", {flag: 'wx'});
      done = true;
    }
    catch (e) {
      pathname = path.join(tmpdir, getHash(vpath + String(n), 'sha256') + ext);
      n++;
    }
  }
  return pathname;
}

 ファイル名の重複を確実に回避するため、空ファイルを作成しておいて、その後Ghostscriptでそのファイルを上書きするという方針にしている。ファイル作成時に「wx」オプションを指定しているので、同名のファイルが存在した場合ファイル作成が失敗する→それを検知して別のファイル名を作成して再度ファイル作成を試みる、を繰り返すというアルゴリズムになっている。

 また、変換処理の実行時にメッセージが表示されてログが汚れるので「-q」オプションを追加して進捗メッセージは非表示にする。オプション配列の後ろの方にこのオプションをつけないとなぜか有効に動かない模様でちょっとハマる。

  const gsCmd = [ "-sDEVICE=jpeg",
                  "-o",
                  destination,
                  "-sDEVICE=jpeg",
                  "-r72",
                  `-dFirstPage=${page}`,
                  `-dLastPage=${page}`,
                   "-q",
                  realPath];

 なお、ここまでスルーしていたが、このアプリ内では電子書籍のパスを仮想パス(vpath)で扱っている。これは設定ファイルで指定した電子書籍ファイルが格納されているディレクトリの絶対パスをMD5でハッシュ化し、そのハッシュ+個別のファイルの相対パスに相当する。うっかりミスでファイルシステム内全体にアクセスできるようになってしまう可能性を減らすためにこのような仕様にしている。

 最終的にPDFファイルのサムネイルを取得する関数は次のような形となった。将来的にJPEG以外の形式でサムネイルを作成する可能性も考慮して一緒にcontentTypeも返している。

function getPdfThumbnail(vpath, page) {
  const pathname = makeTempFile(vpath, ".jpeg");
  generatePdfThumbnail(vpath, pathname, page);

  const data = fs.readFileSync(pathname);
  fs.unlinkSync(pathname);

  return { contentType: 'image/jpeg',
           data: data };
}

OpenAPIのサービス仕様定義の修正とAPIサーバーの修正

 続いては、電子書籍ファイルの仮想パスを指定すると、それに対応したサムネイル画像を返すAPIをAPサーバーに追加する。まずはサービス仕様ファイルにこのAPIを記述する。これは指定された電子書籍ファイルが見つかればimage/jpeg形式で対応する画像を返し、そうでない場合は404を返すというもの。

paths:
  /books/{vpath}/thumbnail:
    get:
      x-swagger-router-controller: Default
      description: Returns a thumbnail of the book
      operationId: getBookThumbnail
      parameters:
        - in: path
          name: vpath
          schema:
            type: string
          required: true
          description: virtual path of the book
      responses:
        "404":
          description: A book with the given vpath was not found
        "200":
          description: A thumbnail of book
          content:
            image/jpeg:
              schema:
                type: string
                format: binary

 この定義ファイルをSwagger Editorに読み込ませて再度サーバーコードを生成してみたのだが、ロジックを毎回再生成されるディレクトリツリーから分離した場所に記述するのが面倒そうな感じだったため、すでに実装しているサーバーコードに手動で追加したパスに対する処理を定義する。といっても編集が必要なのはservice/DefaultService.jsとcontrollers/Default.js、api/openapi.yaml(サービス仕様定義ファイル)の3つのみ。最後のサービス仕様定義ファイルについてはマスターとしている親ディレクトリのもののシンボリックに置き換えておいた。

 まず、service/DefaultService.js内にロジックに相当するコードを記述。このコードは既存コードを参考にしてPromiseを返すようにしている。

/**
 * Returns a thumbnail of the book
 *
 * vpath String virtual path of the book
 * returns byte[]
 **/
exports.getBookThumbnail = function(vpath) {
  return new Promise(function(resolve, reject) {
    const thumb = ebmgr.getThumbnail(vpath);
    if (thumb) {
      resolve(thumb);
    } else {
      resolve();
    }
  });
}

 続いていわゆるコントローラに相当する処理をcontrollers/Default.jsに書く。ここにはHTTPヘッダやレスポンスを返すというWebサーバー的に必要な処理を記述する。このサーバーはexpressjsを使っているので、expressjsの流儀に従ってレスポンス/リクエストオブジェクト経由でレスポンスを返す処理を記述すればOK。

module.exports.getBookThumbnail = function getBookThumbnail (req, res, next, vpath) {
  Default.getBookThumbnail(vpath)
    .then(function (thumb) {
      if (!thumb) {
        // resource not found
        res.writeHead(404);
        res.end();
        return;
      }
      res.writeHead(200, {'Content-Type': thumb.contentType });
      res.end(thumb.data);
    })
    .catch(function (response) {
      res.writeHead(500);
      res.end();
    });
};

 これでAPIサーバーを起動して/docs以下にアクセスしてSwaggerでテストする。問題なく動作したので続いてクライアント側のコードを記述する。

Reactアプリのコード更新

 OpenAPIクライアント経由でgetBooksメソッドを実行して取得した電子書籍リストでは、pathプロパティでその電子書籍ファイルの仮想パスが与えられている。API仕様では/api/v1/books/{URIエンコードされた仮想パス}/thumbnailというパスに対しGETメソッドを投げるとサムネイル画像がimage/jpegというContent-Typeで返ってくるようになっているので、ThumbnailGrid.jsファイル内のrender()メソッドが返すHTML内のimgタグのsrc属性でこのURLを指定する。Reactのテンプレート(JSX)内で文字列連結をする場合、{}の中でJavaScriptコードとして連結処理を記述しなければならないのにちょっとハマる。

render() {
  const makeThumb = x => {
    const encodedPath = encodeURIComponent(x.path);
    return (
        <li key={x.title}>
        <img class="thumbnail" src={"/api/v1/books/" + encodedPath + "/thumbnail"} />
        </li>
    );
  };
  const listItems = this.state.items.map(makeThumb);
  return (
      <div className="ThumbnailGrid">
      <ul>{listItems}</ul>
      </div>
  );
}

 これでReactのテストサーバーを起動してテスト。ちゃんとサムネイルは表示される。

 ただ、画像が表示されるまでちょっと待たされる感じになっている。ログを見るとこんな感じ。

GET /api/v1/books/0f276cdfc8a2c99c988ef7b88141f377%2FOP%E3%82%A2%E3%83%B3%E3%83%95%E3%82%9A%2F6_1-4_Analog_Filteres.pdf/thumbnail 200 136.203 ms - -
   **** Error reading a content stream. The page may be incomplete.
               Output may be incorrect.

   **** Error: File has unbalanced q/Q operators (too many Q's)
               Output may be incorrect.
   **** Error: File did not complete the page properly and may be damaged.
               Output may be incorrect.
GET /api/v1/books/0f276cdfc8a2c99c988ef7b88141f377%2FOP%E3%82%A2%E3%83%B3%E3%83%95%E3%82%9A%2F6_5-8_Analog_Filteres.pdf/thumbnail 200 202.495 ms - -

 ここから、サムネイル生成処理には1つあたりおおむね数百ミリ秒ほどかかっていることが分かる。さすがにちょっとUI的に重いが、とりあえずここの部分の最適化はToDoに突っ込んで置いてまたの機会に。ghostscriptのエラーメッセージについては、画像自体はちゃんと表示されているので現時点では無視。

ZIP形式の電子書籍ファイルからのサムネイル画像取得

 ZIP形式の電子書籍ファイルについても、同じようにサムネイル画像を表示したい。アルゴリズム的にはZIPファイル内のファイルをスキャンして最初に見つかったjpegファイルを取り出せばOKだろう。モジュールとしてはJSZip(https://www.npmjs.com/package/jszip)とADM-ZIP(https://www.npmjs.com/package/adm-zip)というものが見つかった。利用者数はどちらも十分に多く、どっちを選択しても問題なさそうだが、ADM-ZIPはほかに依存するモジュールがない(Dependenciesが0)という点が気に入ったのでそちらを選択。

 サムネイル取得関連のメソッドを整理し、ZIP用とPDF用に分割。すんなりとZIPファイル内から最初の画像ファイルを取得するコードを実装できた(コード全文)。

function getZipThumbnail(vpath) {
  const realPath = vpathToRealPath(vpath);
  const zip = new AdmZip(realPath);
  const rex = /(\.jpeg|\.jpg)$/;

  for (const entry of zip.getEntries()) {
    if (!entry.isDirectory && rex.test(entry.entryName)) {
      console.log(entry.entryName);
      const data = zip.readFile(entry);
      return { contentType: 'image/jpeg',
               data: data };
    }
  }
}

 getThumbnail()関数では拡張子に応じてgetPdfThumbnail()もしくはgetZipThumbnail()を呼び出すことで適切にサムネイルを生成できるようにする。

exports.getThumbnail = getThumbnail;
function getThumbnail(vpath, page) {
  // check given path
  const realPath = vpathToRealPath(vpath);
  if (!realPath) {
    return undefined;
  }

  const ext = path.extname(realPath).toLowerCase();
  if (ext == ".pdf") {
    page = page || 1;
    return getPdfThumbnail(vpath, page);
  }
  if (ext == ".zip" || ext == ".cbz") {
    return getZipThumbnail(vpath);
  }
};

 これでZIP圧縮形式の電子書籍ファイルのサムネイルもとりあえず表示できるようになった。

開発サーバーを使わないアプリ実行(Reactアプリのデプロイ)

 まずはサムネイル画像表示までを実現できたので、ここでReactの開発サーバー外でクライアントを動作できることを確認する。Reactアプリのディレクトリで「npm run build」コマンドを実行すると、アセットがビルドされてリリース用のコードが作成され、buildディレクトリ内にそれら一式が出力される。このディレクトリ内のファイルをWebサーバーで公開することで、開発用サーバーを使わずにアプリを実行できる。

 今回はAPIサーバーのディレクトリ内にReactアプリディレクトリ内のbuildディレクトリへのシンボリックリンクを「public」という名前で作成する。続いてAPIサーバーのindex.jsを修正し、EBM_MODE環境変数の値によってプロキシの有効/無効を切り替えるように変更。これでEBM_MODE環境変数がdevelopment以外の場合、buildディレクトリ内のコンテンツが/以下で公開されるようになる。

// check running environment
const mode = process.env.EBM_MODE || 'development';

if (mode == 'development') {
  // add routes for React
  app.use(/^\/(?!(docs|api)\/).*/, createProxyMiddleware({ target: 'http://localhost:3000', changeOrigin: true }));
} else {
  app.use(express.static('public'));
}

 動作チェックをすると、問題なく動く。Reactアプリはデプロイが面倒そうな先入観があったのだが、意外に簡単だった。ただしnpm run buildコマンドはそれなりに実行に時間がかかるので頻繁に実行することは想定しないほうが良い感じ。

 ということで本日はここまで。作業時間は4時間ほど。