term タグ別の記事一覧

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でアプリを作ってみる(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時間ほど。

Reactでアプリを作ってみる(2日目) - PDFからのサムネイル生成

 前から触ってみたかったReactを使って、前から欲しいと思っていた電子書籍管理アプリを作ってみたレポート2日目です。今日はサムネイル画像生成の話でReactは全然関係ありません。

 電子書籍ファイルのサムネイルを表示させるために、表紙(つまり電子書籍の1ページ目)の画像の生成が必要となる。PDFファイルの場合、なんらかのツールを使って変換処理が必要となる。

 Node.js向けにはpdf-image(https://www.npmjs.com/package/pdf-image)やpdf2pic(https://www.npmjs.com/package/pdf2pic)というPDFを扱えるモジュールがあるが、どちらもimagemagick(graphicsmagick)を使うようだ。しかし、imagemagickはインストールが面倒臭い上に脆弱性が度々発見されていたりするため、あまり使いたくない。結局PDFのラスタライズ機能だけがあれば良い訳で、調べたところGhostscriptライブラリ(libgs)のJavaScriptバインディングであるghostscript4js(https://github.com/NickNaso/ghostscript4js)というものがあり、これを使えば直接GhostscriptをNode.jsアプリから呼び出してPDFを画像化できるようだ。

 なお、GhostscriptのmacOS向けバイナリは公式には公開されていないのでインストールが若干面倒。これについては別記事で書いた。

 実際にこれを使ってPDFからJPEG形式のサムネイル画像ファイルを生成するgenerateThumbnail()というメソッドを実装してみた(コード全文はこちら)。とりあえずこれでサムネイル画像の生成は可能になった。

const gs = require('ghostscript4js');

exports.generateThumbnail = function generateThumbnail(vpath, page) {
  page = page || 1;
  const realPath = _vpathToRealPath(vpath);
  const target = path.parse(realPath);
  const outputPath = path.join(target.dir, target.name + '.jpeg');

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

  gs.executeSync(gsCmd);
  return outputPath;
};

 ghostscript4jsのドキュメントには書いていないが、gs.executeCmdメソッドに渡すGhostscriptのオプションは単なる文字列だけでなく、このように文字列の配列形式で与えることも可能。パス名にスペースを含むファイル名を処理する場合はこのように配列形式で与えないとうまく処理できないようだ。また、"-sDEVICE=jpeg"が複数回登場しているが、なぜかこのように重複して指定しないとうまくファイルが生成されなかった。

 さて、この実装だと電子書籍ファイルと同じディレクトリにサムネイル画像を生成することになる。そのあたりの仕様をどうするか検討する必要がある。

 2日目は(Ghostscriptのビルドでちょっと時間を食ってしまったので)ここまで。作業時間は3時間ほど。

GW引き篭もりチャレンジ:Reactでアプリを作ってみる(1日目)

 世間は5連休ですが、特に出かけることもできず、日曜大工や電子工作の材料も入手ができずと普段休日にやっていることをほぼ封じられている毎日です。しょうがないので、前から触ってみたかったReactを使って、前から欲しいと思っていた電子書籍管理アプリを作ってみたレポートです。

 仕事ではないので、特に事前調査などをせずに行き当たりばったりでコードを書いていきます。また、このレポート記事も(仕事ではないので)Twitter並みに雑にやった作業を書いていきます(ちなみに、記事を書いているのはリアルタイムではありません。メモ書きはリアルタイムでやっていますが、整理・校正は5日目にまとめてやっています)。

 さて、作成するアプリの要件は次のとおり。

  • いわゆる「自炊」で作成した電子書籍ファイルを管理する
  • 電子書籍ファイルの形式はPDFと画像が入ったフォルダをZIP形式で圧縮したものの2種類
  • 指定したディレクトリ内の電子書籍ファイルの表紙(1ページ目)のサムネイルをタイル状に表示する
  • サムネイルをダブルクリックするとビューアが起動してそのファイルを開く

 将来的にメタデータによる管理などもできると良いのだが、まずは動くものを実装することと、Reactの全体像やアーキテクチャを掴むことを目標にする。そのため、パフォーマンスについてはあまり気にしない。ただ、ある程度正しいアーキテクチャというものは意識してコードを書いていく。

開発環境の構築&Reactの概要を掴む

 まずはReactのチュートリアル(https://reactjs.org/tutorial/tutorial.html)をざっと眺めるとともに、このチュートリアル内の「Setup Option 2: Local Development Environment」を参照して「npx create-react-app <アプリ名>」コマンドで開発環境を作る。Reactの特徴は、表示するコンポーネントをクラスとして定義して、そこにHTMLのテンプレートを直接書き込む点にあるようだ。今回は「ebmgr」という名前でアプリを作る。

$ npx create-react-app ebmgr

 ちなみにnpxコマンドはNode.jsのパッケージマネージャであるnpmコマンドに同梱されているもので、指定されたパッケージのインストールとそこに含まれているコマンドの実行を同時に行うというもの。

 create-react-appで作成されたディレクトリ内のsrcディレクトリ内にソースコードが、publicディレクトリ内に開発用Webによって公開されるindex.htmlや画像、CSSなどが配置されている。

$ ls
README.md		node_modules		package-lock.json	package.json		public			src

$ ls public/
favicon.ico	index.html	logo192.png	logo512.png	manifest.json	robots.txt

$ ls src/
App.css			App.test.js		index.js		serviceWorker.js
App.js			index.css		logo.svg		setupTests.js

 とりあえずチュートリアルの指示に従って「npm start」コマンドを実行してみると、Webブラウザで作成したアプリケーションの雛形が動いた。index.html内にはJavaScriptファイルやCSSファイルを読み込むようなタグは入っていないので、それらはテストサーバーが動的に挿入している模様。

 また、srcディレクトリ以下のApp.jsを見てみると、そこにブラウザで表示されているアプリケーションのHTMLコードが直接記述されている。これを変更して保存すると、リアルタイムでブラウザの画面がそれに追従して更新される。便利。

import React from 'react';
import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

 ちなみにこのようにJavaScript中に直接HTMLが書き込まれているフォーマットをJSXと呼ぶらしい。

 JSXファイル内ではSVGファイルやCSSファイルをimport文でインポートできるようだ。また、テンプレート内での変数展開は「{」「}」で行える模様。一般的なテンプレートエンジンはforやwhileのようなループ機構を持っているが、JSXはそういった機構は持っていないようなので、たとえばArrayで格納されているテキストを順に表示したいといった場合、単純にJavaScriptコードで次のように展開してやれば良い。

function App() {
  const items = ["foo", "bar", "hoge"];
  const listItems = this.state.items.map(x => <li key={x}>{x}</li>);
  return (
      <div className="ThumbnailGrid">
      <ul>{listItems}</ul>
      </div>
  );
}

 このApp.jsというのは「App」というコンポーネントを定義しているだけで、ページ全体のレンダリングはindex.jsによって行われている模様。コードをみると、このReactDOM.render()メソッドで表示する内容や、HTML内でそれを挿入するDOM要素を指定している。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

 まずは電子書籍のサムネイル画像をグリッド表示させたいので、App.jsをコピーして「ThumbnailGrid.js」というファイルを作る。

import React from 'react';

function ThumbnailGrid() {
  const items = ["foo", "bar", "hoge"];
  const listItems = this.state.items.map(x => <li key={x}>{x}</li>);
  return (
      <div className="ThumbnailGrid">
      <ul>{listItems}</ul>
      </div>
  );
}

export default ThumbnailGrid;

 index.jsを編集し、このThumbnailGrid.jsをインポートした上でと記述されていたものをに差し替えてみる。無事動作。ついでに今回ServiceWorkerは使わないのでそれ関連のコードを削除。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import ThumbnailGrid from './ThumbnailGrid';

ReactDOM.render(
  <React.StrictMode>
    <ThumbnailGrid />
  </React.StrictMode>,
  document.getElementById('root')
);

Reactアプリに表示する情報を取得するためのAPIをOpenAPIで実装する

 ReactではNode.jsのモジュールをインポートできるようだが、さすがにfsなどのモジュールは使えないようだ。そのため、ローカルファイルシステム上にあるファイルの情報などを表示するにはREST APIなどを使ってサーバーと別途非同期通信を行う必要がある。create-react-appで作成した環境では「npm start」コマンドを実行すると開発用サーバーが立ち上がるが、この開発用サーバーはReactアプリを提供する機能しかないため、別途APIを提供するWebサーバーを用意する必要がある。

 最近ではREST APIといえばOpenAPI(https://www.openapis.org)ですよね、ということでOpenAPI Specification(https://swagger.io/specification/)を見ながら電子書籍データのリソースを定義した最低限のサービス仕様ファイルを作成する。ここでは「/books」にGETリクエストを投げることで、タイトルとファイルパスを含む配列を返すというAPIを定義した。

openapi: 3.0.5
info:
  title: ebook manager (ebmgr) App
  description: ebook manager API
  version: 0.1.0
servers:
- url: http://localhost:3333/api/v1
  description: Local server
paths:
  /books:
    get:
      description: Returns all books list
      operationId: getBooks
      responses:
        "200":
          description: A list of books
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Book'
                x-content-type: application/json
      x-swagger-router-controller: Default
components:
  schemas:
    Book:
      type: object
      properties:
        title:
          type: string
          description: title of book
          example: some interesting title
        path:
          type: string
          description: virtualized path of book
          example: /foo/bar/baz
      example:
        title: some interesting title
        path: /foo/bar/baz

 なお、あとでツールを使ってここからサーバーコードを生成するのだが、ここで定義している「operationId」という要素がメソッド名にマッピングされる。省略すると勝手にダサい名前をつけられる可能性があるので気をつけましょう。

 Swagger Editor(https://editor.swagger.io)に作成したサービス仕様ファイルをアップロードすると、ここで作成したスキーマを検証したり、「Generate Server」や「Generate Client」メニューからサーバー/クライアントコードをダウンロードできるようになる。今回はNode.jsでサーバーも書くので「nodejs-server」を選択。するとzip圧縮されたサーバーコードがダウンロードされる。

 api-serverというディレクトリを作ってダウンロードしたzipファイルを展開して、そこで「npm start」コマンドを実行するとindex.jsファイルが実行されAPIサーバーが立ち上がる。なおこのサーバーはexpressを利用して実装されているようだ。標準では3000番ポートで待受をするが、これはReactの開発サーバーとバッティングするので3333番ポートに変更している。

const path = require('path');
const http = require('http');
const oas3Tools = require('oas3-tools');

const serverPort = 3333;

// OAS3 API server
const options = {
  controllers: path.join(__dirname, './controllers')
};
const oasDefinition = path.join(__dirname, 'api/openapi.yaml');
const expressAppConfig = oas3Tools.expressAppConfig(oasDefinition, options);
expressAppConfig.addValidator();
const app = expressAppConfig.getApp();

http.createServer(app).listen(serverPort, function () {
    console.log('Your server is listening on port %d (http://localhost:%d)', serverPort, serverPort);
    console.log('Swagger-ui is available on http://localhost:%d/docs', serverPort);
});

 サービス仕様に記述したパスに対応する処理はservice/DefaultServices.jsに書けば良いようだ。将来的にこのapi-server以下のコードは書き直すことがあるかもしれないので、実際のロジックはこのディレクトリ外のファイル(../../ebmgr.js)に記述する。

 まず必要なのは指定したディレクトリ内にある電子書籍ファイルの情報を返すコードなので、getContents()というメソッドを実装する(コード全文はこちら)。

function getContents() {
  const targetDirs = config.contentDirectories;
  var results = [];
  for (const dir of targetDirs) {
    const r = searchContents(dir);
    results = results.concat(r);
  }
  return results;
}

function searchContents(dirname) {
  const dirnameHash = getHash(dirname);
  const r = [];
  _searchContents(dirname, dirnameHash, r);
  return r;

  function _searchContents(dirname, dirnameHash, results) {
    const dir = fs.readdirSync(dirname, {withFileTypes: true});
    const exts = config.targetExtentions;
    for (const item of dir) {
      if (item.isDirectory()) {
        _searchContents(path.join(dirname, item.name),
                        path.join(dirnameHash, item.name),
                        results);
        continue;
      }
      for (const ext of exts) {
        if (item.name.endsWith(ext)) {
          const metadata = {};
          metadata.title = path.basename(item.name, ext);
          metadata.path = path.join(dirnameHash, item.name);
          results.push(metadata);
          continue;
        }
      }
    }
  }

}

exports.getContents = getContents;

 これを、service/DefaultService.js内で呼び出す。

const ebmgr = require('../../ebmgr.js');
/**
 * Returns all books list
 *
 * returns List
 **/
exports.getBooks = function() {
  return new Promise(function(resolve, reject) {
    const items = ebmgr.getContents();
    resolve(items);
  });
}

 Swagger Editorから生成されたサーバーコードは、デフォルトでSwagger的な機能が有効になるように設定されており、api-serverディレクトリで「npm start」(もしくは「node index.js」)コマンドでサーバーを起動し、Webブラウザでそのサーバーの/docs/以下にアクセスすることで、リソース一覧を確認したり、フォームで引数を指定してリソースにアクセスすることができる。これで試したところ、とりあえず目的のデータは得られているようだ。

 ついでにAPIサーバー(今回は3333番ポートを使用)からReactの開発サーバー(3000番ポートを使用)にアクセスできるようAPIサーバーにリバースプロクシを設定。これでこのAPIサーバーに対する/docs/以下と/api/以下以外のアクセスはすべてReactの開発サーバー(localhost:3000)に転送される(コード全文)。

const { createProxyMiddleware } = require('http-proxy-middleware');

const app = expressAppConfig.getApp();

// add routes for React 
app.use(/^\/(?!(docs|api)\/).*/, createProxyMiddleware({ target: 'http://localhost:3000', changeOrigin: true }));

 続いてはReactアプリ側でAPIを呼び出すコードの実装。Swagger Editorにはクライアントコードを生成する機能もあるのだが、「Generate Client」-「javascript」でダウンロードしたコードをReact内のコードから呼び出そうとしたらWebpackのエラーで動かない。雑にググったところ、「swagger-client」というものを使えという話があったのでこちらをReactアプリのディレクトリ内でnpmコマンドを実行してインストール。

$ npm i swagger-client

 今回は「ThumbnailGrid」コンポーネント内で電子書籍ファイルのサムネイル画像を表示させたいので、このコンポーネントを定義しているThumbnailGrid.js内でswagger-clientをimportする。

import React, {Component} from 'react';
import SwaggerClient from 'swagger-client';

 あとはThumbnailGrid()メソッド内でこのクライアントを使ってgetBooks APIを叩いてデータを取得し、それをHTMLとしてレンダリングすれば良いのだが、このクライアントは結果を非同期で(Promiseを使って)返す仕組みになっている。つまり単純にメソッドの返り値として取得したデータを返すことはできない。ということでググると、コンポーネントをReact.Componentクラスの派生クラスとして定義することで色々非同期に処理ができるようになるようだ。

 コンポーネントのライフサイクルなどはVue.jsでも登場した概念でほぼ同じ。最初にconstructor()が→render()→copmponentDidMount()という順序で実行される。コンポーネント固有のデータはconstructor()内で定義したstateオブジェクトで管理するようだ。ということで、copmponentDidMount()内でswagger-client経由で電子書籍データのgetを実行することにする。

 swagger-clientは初期化時にサービス仕様定義ファイルを与えるようになっているが、こちらはAPIサーバーの/api-docsからGETできる。

componentDidMount() {
  const client = new SwaggerClient('http://localhost:3333/api-docs');
  client.then(c => {
    c.apis.default.getBooks().then(results => {
      this.setState({ items: results.body,
                      loading: false,
                    });
    });
  });
}

 ReactではsetState()メソッドでstateを更新するようになっており、これに応じてrender()メソッドが実行されてコンポーネントの再レンダリングが実行される。UIライブラリではよくある設計ですね。

 ということで、render()メソッドにstateオブジェクトを元にコンテンツをレンダリングする処理を記述する。

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

 また、constructor()でstateの初期化をしておく。

class ThumbnailGrid extends Component {
  constructor () {
    super();
    this.state = { items: [],
                   loading: true,
                 };
  }

 これでReactの開発サーバーとAPIサーバーを動かして、APIサーバーにアクセスするととりあえずファイルのタイトル(ファイル名)一覧が表示される。

 1日目はここまで。本日の作業時間は4時間ほど。

Node.jsでWebAppの開発に必要なN個のこと

 ※元ネタは「PerlでWebAppの開発に必要なN個のこと

 あるプログラミング言語で実際にWebAppを開発できるようになるまで、何が必要だろうか。言語仕様の習得は終えているとしよう。おそらく、最低限以下のような知識が必要だと思われる。とりあえずNode.jsについて知っていることを書いた。

パッケージマネージャ

 ライブラリの管理には、Node.jsに同梱されているnpmを利用する。Node.js向けに公開されているパッケージのほぼすべてはnpm経由で入手が可能だ。

 npmでは通常アプリケーション個別のディレクトリにパッケージがインストールされ、システム全体でのパッケージの共有は行わないため、バージョン管理は容易である(オプションでシステム全体で共有するようにパッケージをインストールすることも可能)。

アプリケーションサーバー

 Node.jsには標準でhttpモジュール/httpsモジュールというHTTP/HTTPSサーバーが組み込まれている。別途Webサーバーを用意する必要は無い。

リクエストパラメータの処理

 自前で実装するのであれば、標準で含まれているurlモジュールやquerystringモジュールを使ってパースができる。フォームの処理を行うなら、Formidableモジュールなどを使うと簡単だ。

ルーティング

 単体のルーティングモジュールとしては、今のところNode.jsで決定的なものはない。http/httpsモジュールではリクエストを受け取るとイベントハンドラにリクエストされたパスやパラメータを含んだオブジェクトを渡すので、それらを見てif文などで自前でルーティング処理を実装してもたいした手間ではない。もちろん、npmにはいくつかのルーティング用モジュールが登録されている。

データベース

 統一的なインターフェイスはないが、主要なデータベースに対するモジュールは一通り用意されている。たとえばMySQLであればnode-mysqlなど。

ビューのレンダリング

 JavaScriptコードをHTML内に埋め込むタイプであればEJSが広く使われている。そのほか、Hogan.jsjadeなどお好みで。JSONを生成したいのであれば、JSON.stringfy()で可能。

HTTPクライアント

 Node.js標準のhttpモジュールに含まれている。

テストフレームワーク

 Vowsとかmochaとかが有名。

WAF(Web Application Framework)

 有名なのはexpress。ルーティングやリクエストのパースを容易にする機能が多数提供される。また、ルーティングを自前でやりたいというのであればexpressのベースとなっているConnectもチェックすると良い。また最近ではSails.jsというWAFが注目されているようだ。

Node.jsからMongoDBにバイナリデータを格納する

 MongoDBにデータを格納する場合、格納するデータをプロパティとして持つオブジェクトを引数にコレクションのinsertメソッドを実行する。もしバイナリデータをデータベースに格納したい場合、バイナリデータをBuffer型のオブジェクトとして与えればよい。ただし、MongoDBに格納するドキュメント(データを格納する単位。SQLデータベースの1行に相当する)には最大4MBというサイズ制限がある。そのため、格納できるバイナリデータの上限は4MBだ。

 次のサンプルコードは、引数で指定したファイルを読み込んでデータベースに格納するものだ。

 データベースに格納するオブジェクトを用意しているのが42行〜45行の部分だ。ここでdataオブジェクトは読み出したデータを格納しているBuffer型のオブジェクトとなっている。

 ちなみに、格納されたデータをmongoシェルで確認すると、BASE64でエンコードされた形で表示される。

$ node store-to-mongodb.js ./test/sample.JPG
insert succeeded.
 
$ mongo test01
> db.images.find()
{ "filename" : "sample.JPG", "data" : BinData(0,"/9j/4AAQSkZJRgABAQEAtAC0AAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
:
:

 逆にデータベースからバイナリデータを読み出した場合、読み出したバイナリデータはmongodb.Binary型のオブジェクトに格納される(mongoDB Node.js Driverのドキュメント)。このオブジェクトから格納しているデータを取り出すには、readメソッドを使用する。オブジェクトのbufferプロパティを直接参照することでもバイナリデータにアクセスできるのだが、ドキュメントにはこのプロパティが明示されていないので、readメソッドを使用したほうが確実だ。

binary.read(position, length)

 ここでposition引数にはデータの読み出しを開始するオフセットを、length引数には読み出すバイト数を指定する。

 次の例は、読み出したデータをファイルに保存するものだ。

 データの取り出し処理をしているのは60〜61行の部分だ。まずlengthメソッドでバイナリのサイズを取得してlen変数に格納し、0バイト目からlen変数で指定したサイズまで、つまりバイナリの最初から最後までを取り出してコールバック関数を実行している。

Connectミドルウェア「logger」でExpressアプリケーションのアクセスログ/エラーログを記録する

 Expressでは、Connectミドルウェア「logger」を使うことで詳細なアクセスログやエラーログを記録することができる。Expressコマンドで生成したスケルトンコードでは、以下のようにloggerミドルウェアを利用するように設定されている。

app.configure(function(){
  :
  :
  app.use(express.logger('dev'));
  :
  :
}

 このように第一引数に'dev'引数を与えて生成されたloggerミドルウェアを使用すると、標準出力にカラー付きでアクセスログが出力される。これは開発用という目的なので、実サービスの運用には適していない。そこで、Apacheのデフォルト設定で使われる形式のアクセスログおよびエラーログをファイルに出力するよう設定していく。

 なお、下記ではExpress 3.1.0およびこれが依存しているConnect 2.7.2ベースを使用している。

logger型オブジェクトを生成する

 loggerミドルウェアオブジェクトのファクトリ関数は、以下のような引数を取る。

logger([options])

 options引数にはオプション情報を格納したオブジェクト、文字列、関数オブジェクトを与えることができる。省略された場合は空のオブジェクト({})が与えられたものと見なされる。また、文字列もしくは関数オブジェクトが与えられた場合、次のようなオブジェクトが与えられたものと見なされる。

{format: <与えられた文字列もしくは関数オブジェクト>}

 options引数に与えられたオブジェクトのうち、表1のプロパティが挙動に影響を与える。

表1 options引数で有効なプロパティ
プロパティ名説明デフォルト値
immediate非falseの値を指定すると、リクエストを受信した時点でログ出力を実行する。false、もしくはundefinedの場合、レスポンスの送信が完了した時点(responseオブジェクトのendイベントハンドラが呼び出されるタイミング)でログ出力を実行するundefined
format出力フォーマットを指定する。表2のどれかに該当する文字列が指定された場合、それに対応する定義済みフォーマットが使用される。それ以外の文字列が指定された場合、その文字列をフォーマット文字列として使用する'default'
streamログを出力するストリームを指定するprocess.stdout
bufferバッファを利用する場合、非falseの値を指定する。数値が指定された場合、フラッシュする間隔をミリ秒で指定するundefined

 また、options.formatプロパティでは出力形式を指定できるが、ここでは表2の定義済みフォーマット値が利用できる。「tiny」と「dev」はほぼ同じ内容を出力するが、devはコンソール出力時に見やすいよう色付き表示のためのエスケープシーケンス付きで出力される点が異なる。

表2 使用できる定義済みフォーマット
定義済みフォーマット名フォーマット文字列
default:remote-addr - - [:date] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"
short:remote-addr - :method :url HTTP/:http-version :status :res[content-length] - :response-time ms
tiny:method :url :status :res[content-length] - :response-time ms
dev:method :url :status :response-time ms - :res[content-length]

 なお、フォーマット文字列では表3のトークンが利用できる。これらは出力時に適切な値に変換される。

表3 フォーマット文字列で使用できるトークン
トークン名変換後の文字列
:urlreq.originalUrl || req.url
:methodreq.method
:response-timenew Date - req._startTime
:datenew Date().toUTCString()
:statusres.statusCode
:referrerreq.headers['referer'] || req.headers['referrer']
:remote-addrreq.ipもしくはsock.socket.remoteAddress、sock.remoteAddress
:http-versionreq.httpVersionMajor + '.' + req.httpVersionMinor
:user-agentreq.headers['user-agent']
:req[<リクエストヘッダ名>]指定したリクエストヘッダの値
:res[<レスポンスヘッダ名>]指定したレスポンスヘッダの値

 定義済みフォーマットで所望の出力が得られない場合、表3のフォーマット文字列を使って独自のフォーマットを定義することも可能だ。さらに、options.formatプロパティに独自のログ整形処理を実装した関数オブジェクトを与えることもできる。この場合、この関数オブジェクトはログを出力するタイミングで(exports, req, res)という引数が与えられて実行される。exports引数にはloggerモジュールが、req引数にはリクエストオブジェクトが、res引数にはレスポンスオブジェクトが与えられ、この関数の戻り値がログとして指定されたストリームに出力される。

Apacheのデフォルト設定で使われる形式のログを出力する

 以下はApacheのデフォルト設定で出力されるログの例だ。

192.168.0.1 - - [24/Mar/2013:06:07:06 +0900] "GET /feed/ HTTP/1.1" 304 - "-" "hogehoge user-agent"

 このとき、各フィールドには次のような情報が格納されている。なお、定義されていない/取得できない値に対応するフィールドには「-」が出力される。

<リモートホスト名> <identd(mod_ident)が提供するリモートログ名> <リモートユーザー名> <アクセス日時> "<HTTPリクエストヘッダ>" <ステータスコード> <転送バイト数> "<Refererリクエストヘッダの値>" "<User-Agentリクエストヘッダの値>"

 これと同じ形式のログをloggerミドルウェアを使って出力するには、定義済みフォーマットの「default」を利用すればよい。たとえば/home/foobar/access_logというファイルにログを出力するには、以下のようにする。

app.configure(function(){
  :
  :
  var logStream = fs.createWriteStream('/home/foobar/access_log', {mode: 'a'});
  app.use(express.logger({
    format: 'default',
    stream: logStream || process.stdout
    }));
  :
  :
}

Express環境で一部のURLに対するリクエストのみリバースプロクシを使って転送する

 hylom.netではNode.jsベースのCMS(nodeweblog)で運用されているが、以前はWordPressを使用して運用していた。このようなCMSの切り替えの際、過去コンテンツへのアクセスをどう処理するか、ということを考えなければならない。本来ならば旧システムでデータをエクスポートした上で適切に新システムにインポートするべきだが、今回はテスト的な運用と言うことで、別ポートでApache+WordPressを起動し、nodeweblog内に存在しないURLに対するリクエストはリバースプロクシを使ってWordPressに転送するという方法で旧コンテンツへのアクセスを可能にしている。

 Node.jsを使ってリバースプロクシを構築する場合、node-http-proxyといったモジュールを利用する、もしくはリクエストハンドラ内でhttp.request関数を使用する、といった方法があるが、nodeweblogではexpressフレームワークを使っているので、connect-proxyというconnectミドルウェアを利用してリバースプロクシを実現してみた。

 connect-proxyは「proxy-middleware」というパッケージ名で提供されており、npmでインストールできる。

$ npm install proxy-middleware

 たとえば「/api」というパス名で始まるリクエストを「http://example.com/」に転送するには、ミドルウェアの設定を行う部分に以下のようなコードを追加すればよい。

var proxy = require('proxy-middleware');
var proxyOptions = url.parse('http://example.com/');
app.use(proxy(proxyOptions));

 proxy-middlewareモジュールにはリクエストを転送先のプロトコルやホスト名、パス名などを格納したオブジェクトを与える。ここでは、url.parse関数を使ってこのオブジェクトをURLから生成している。この場合、実際に与えているオブジェクトは以下のようになっている。

> url.parse('http://example.com/')
{ protocol: 'http:',
  slashes: true,
  host: 'example.com',
  hostname: 'example.com',
  href: 'http://example.com/',
  pathname: '/',
  path: '/' }
> 

 なお、proxy-middlewareは通常HTTPヘッダなどの書き換えは行わず、ホストおよびパスのみを書き換えてリクエストを転送する。HTTPヘッダを書き換えたい場合、proxy-middlewareに与えるオブジェクトのheadersプロパティに書き換え後のヘッダ情報を格納しておけばよい。たとえばHOSTヘッダを「example2.com」に書き換えたい場合、以下のようにすればよい。

var proxy = require('proxy-middleware');
var proxyOptions = url.parse('http://example.com/');
proxyOptions.headers = {'host': 'example2.com'};
app.use(proxy(proxyOptions));

hylom.netのアップデート

 hylom.netのCMSを、いままで使っていたWordPressからNode.jsで実装した独自のシステムに移行させました。WordPressに大きな不満はなかったのですが、もう少し気軽に記事を書きたいと思ったのと、Node.jsベースのシステムであればもう少しパフォーマンスを出せるのではないかと思ったのが移行のきっかけです。

 システムとしては、先日発売されたNode.jsの書籍「はじめてのNode.js -サーバーサイドJavaScriptでWebアプリを開発する-」で解説しているブログシステム「nblog」をベースにカスタマイズしています。まだまだ最低限の機能しか実装していないのですが、今後デザイン面も含めて改善予定ですのでご期待ください。

 また、旧hylom.netのコンテンツは従来のURLでそのままアクセスが可能なようになっています。こちらは、WordPressを別のポートで稼働させた上でNode.js側でリバースプロクシ的な処理をするコードをブログシステムに埋め込むことで実現しています。一応WordPressのデータのインポートをする仕組みは作っているのでいつかは旧コンテンツもNode.jsベースのシステムで閲覧できるようにする予定です。