term タグ別の記事一覧

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時間ほど。

Gitでブランチを間違えて作業した上にpushまでしちゃった場合の対処方法

 時間ができたのでHandBrake 0.9.5の日本語化に着手しているのだが、うっかり0.9.4のブランチで作業してしまったあげく、SourceForge.JP上のリポジトリにpushしてしまい途方に暮れる。

 まあこういうミスをやる人は少ないだろうが、何かのヒントになるかもしれないので対処法をメモしておく。まず、「git log」コマンドで巻き戻したいcommitのハッシュを調べる。

$ git log
 :
 :
commit 1485a5a2bbbb43eedbe131c919b7d604bcbd506d
Author: unknown <hirom@.(none)>
Date:   Tue Jan 5 19:19:44 2010 +0900

    update Installer, changelog, readme

 今回は、この「1485a5a2bbbb43eedbe131c919b7d604bcbd506d」というcommitまで巻き戻すことにする。作業中のブランチが操作したいものであることを確認したうえで、「git reset <対象のハッシュ>」を実行する。

$ git branch -a
  jp-0.9.3
* jp-0.9.4
  master
  original
  remotes/origin/HEAD -> origin/master
  remotes/origin/jp-0.9.3
  remotes/origin/jp-0.9.4
  remotes/origin/master
  remotes/origin/original

$ git reset 1485a5
Unstaged changes after reset:
M       Jamrules
M       Makefile
M       win/C#/Installer/Installer.nsi
M       win/C#/InstallerJp/doc/AUTHORS
M       win/C#/InstallerJp/doc/CREDITS
M       win/C#/InstallerJp/doc/NEWS
M       win/C#/Properties/AssemblyInfo.cs
M       win/C#/frmAbout.Designer.cs

 「git log」で巻戻ったことを確認する。

$ git log
commit 1485a5a2bbbb43eedbe131c919b7d604bcbd506d
Author: unknown <hirom@.(none)>
Date:   Tue Jan 5 19:19:44 2010 +0900

    update Installer, changelog, readme

 バージョン管理されているファイル自体は巻き戻されていないので、「-f」オプション付きでチェックアウトしてファイルも巻き戻す。

$ git checkout -f

 ここまでの作業でローカルブランチの巻き戻しは完了。続いてリモートブランチの巻き戻しを行う。ただし、当然ながらそのままpushすることはできない。

$ git push
Enter passphrase for key '/d/Users/hirom/.ssh/id_dsa':
To hylom@git.sourceforge.jp:/gitroot/handbrake-jp/handbrake-jp.git
 ! [rejected]        jp-0.9.4 -> jp-0.9.4 (non-fast-forward)
error: failed to push some refs to 'hylom@git.sourceforge.jp:/gitroot/handbrake-
jp/handbrake-jp.git'
To prevent you from losing history, non-fast-forward updates were rejected
Merge the remote changes (e.g. 'git pull') before pushing again.  See the
'Note about fast-forwards' section of 'git push --help' for details.

 そこで、いったんリモートブランチを削除した上で再度pushする。まずは削除。リモートブランチの削除は、「git push <リモートリポジトリ> :<対象リモートブランチ>」で行える。

$ git push origin :jp-0.9.4
Enter passphrase for key '/d/Users/hirom/.ssh/id_dsa':
To hylom@git.sourceforge.jp:/gitroot/handbrake-jp/handbrake-jp.git
 - [deleted]         jp-0.9.4

 あとは再度ローカルリポジトリをpushするだけ。

$ git push origin jp-0.9.4:jp-0.9.4
Enter passphrase for key '/d/Users/hirom/.ssh/id_dsa':
Counting objects: 233, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (146/146), done.
Writing objects: 100% (165/165), 30.48 KiB, done.
Total 165 (delta 119), reused 0 (delta 0)
To hylom@git.sourceforge.jp:/gitroot/handbrake-jp/handbrake-jp.git
 * [new branch]      jp-0.9.4 -> jp-0.9.4

 お粗末様でした。

BSD版sedとGNU版sed

 BSD版のUNIX toolsと、GNU版のUNIX toolsでは微妙に受け付けるコマンドラインオプションが違うのは有名な話。たとえばGNU版の「cp -a」はBSD版では「cp -pr」に相当する。

 で、Mac OS XのコマンドラインツールはBSD版なのだが、「sed -e “s/ /\n/g”」が効かなくて困った。Mac OS X付属のsedは置換後の文字の指定にエスケープシーケンスが使えないらしい。

 これの簡単な解決はこちら。

perl -pe "s/ /\n/g"

 「perl -p」でsedっぽい動作をさせられる、という話でした。よくこんなの覚えてたな>自分。ラクダ本の最初のほうとかに書いてあったんだっけ? ラクダ本を読んだのは多分10年くらい前だと思うんだけど、IT業界の進化は早いとか言われていて3年前の知識はもう古いとか言われているけど意外に昔覚えたことってふと全然違うことで役立つことがあるから侮れないね。

gfxbootで日本語表示可能なブートローダを作成しようとする

  GParted Live CD をWindows Vista上でUSBメモリにインストールしようとしたらUAEの仕様がアレでハマる。

 GParted Live CDのUSBメモリ用配布アーカイブにはWindows用のISOLINUXインストーラが付属していて、USBメモリにファイル一式をコピー→バッチファイル実行でブータブルUSBメモリができあがるはずなのだが、VistaだとUSBメモリのブートローダ領域への書き込みには管理者権限がいるそうだ。そりゃそうだ。しかし、かといってバッチファイルを「管理者として実行」するとエラー。なぜかバッチファイルを「管理者として実行」すると、system32ディレクトリがカレントディレクトリになるのが原因。なんじゃそりゃ。

 しょうがないのでバッチファイルをいじくってたらつい日本語化してしまい、ついでにじゃぁほかの部分も日本語化するか、どうせ設定ファイルいくつか書き換えればできるだろと手をつけたのがハマり始まり。そんな簡単なモンじゃなかった。

 Ubuntuのインストーラとかはブート時にブートメニューが日本語で表示されるんだけど、これはgfxbootというものを使っているらしい。gfxbootはSYSLINUX/ISOLINUX/GRUBなどで利用できるプログラムで、それぞれパッチが当てられたSYSLINUX/ISOLINUX/GRUBが必要。原理としてはSYSLINUX/ISOLINUX/GRUBがgfxbootのランタイム(gfxmenu)を起動→gfxmenuがメニュースクリプトを実行、という感じ。このメニュースクリプトは独自言語で記述しておいて、あらかじめバイトコンパイルして1ファイルにまとめた上でブートローダと一緒に置いておくのだが、この独自言語がスタック志向の非常に面倒臭い言語で困る。プログラム中でメモリを動的に割り当てられたり、ファイルのリードもできるのだが、完全に独自仕様(しいて言えばPostScriptに似ているらしい)。

 gfxbootを使っているUbuntuやopenSUSEのブートローダは、基本的にはスクリプト部分は手を加えずに設定ファイルで画像なりメニュー項目をいじれるようになっているんだが、さすがにGParted Live CDを起動したらUbuntuのメニューが、というのはダサいのでざくっと手をいれてみようとしたが2時間くらい格闘して一時中断。これは週末に持ち越しだな。

 ちなみに gfxboot referenceGfxboot – openSUSE あたりが参考資料。日本語の情報はほぼ皆無(笑)。