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

Emacs 23.2導入

 今までWindows環境での物書きにはNTEmacs JPプロジェクトがリリースしている<a href=https://sourceforge.jp/projects/ntemacsjp/releases/“>Emacs 22ベースのWindows向けバイナリを使っていたわけですが、最新安定版であるEmacs 23系ではマルチバイト文字の扱いを含めた色々な改善が加わっているとのことで、移行してみることに。

 NTEmacs JPプロジェクトではEmacs 23ベースのWindows向けバイナリをリリースしていないのですが、Gnupackというプロジェクトで日本語関連(というかIME)のパッチが適用されたEmacs 23.2のバイナリが公開されているので、そちらを利用。プロジェクトページはこちらNTEmacs 23.2はこちらからダウンロード可能。

 フォントなどの基本的な設定方法はEmacs 22系と同じ模様で、たとえばMSゴシックを使うには下記のような設定を.emacsに記述すればOK。

; 「msgochic」という名前で新たなフォントセットを定義
; 英字フォントとしてMS ゴシック、14ポイントを使用
(create-fontset-from-ascii-font
 "-outline-MS ゴシック-normal-r-normal-normal-14-*-*-*-*-*-iso8859-1"
 nil "msgochic")

; myfont-msgochicの日本語フォントとしてメイリオを使用
(set-fontset-font "fontset-msgochic"
                  'japanese-jisx0208
				  '("MS ゴシック" . "jisx0208-sjis"))

; myfont-msgochicのカタカナフォントとしてメイリオを使用
(set-fontset-font "fontset-msgochic"
                  'katakana-jisx0201
                  '("MS ゴシック" . "jisx0201-katakana"))

; 定義したフォントセットを登録
(add-to-list 'default-frame-alist
			 '(font . "fontset-msgochic"))

 あと、とりえあず様子見とのことでmule-ucs関係はすべて外してみましたが、今のところ問題は見られません。コピペで全角チルダ(~)が波ダッシュ(〜)になってしまう問題も解決されている模様。NTEmacs 22とは別のフォルダにインストールすることで共存も可能なので、ひとまずこれで様子見。

DSエミュで(合法的に)遊ぶ

 最近DeSmuMEなるニンテンドーDSエミュレーターが(一部で)ブームらしい。Googleトレンドの検索結果を見てもその盛り上がりは明らかで、2010年9月中旬(というか9月18日)に大きな山があることが分かる。この9月18日はポケモン新作の発売日ということで、まぁなんだかなぁ、という感じではあるのだが……。

DeSmuMEのトレンド

DeSmuMEのトレンド

 で、ニンテンドーDSエミュレーターというと数年前に触ったときはまだまだ完成度が低く、とても市販のゲームが動かせる状況ではなかったのだが、現在では完成度が上がり、結構色々と動作するらしい。ということで、とりあえず久しぶりに試して見ようと思ったら色々とはまったのでメモ代わりにご紹介。

DeSmuMEほかの入手

 まずはDeSmuMEの入手法だが、DeSmuMEのダウンロードから入手できる。Windows/Mac OS X/Linux/ソースが公開されているので、適当なものをどうぞ。Windows版の場合、ZIPで圧縮されているだけなので適当に展開すればOKだ。

 続いて動かすアプリの入手。最近では完成度の高いHomebrew(非オフィシャルな開発ツールを使って有志が制作したソフトの総称。ほとんどが無料で公開されている)も増えてきており、とりあえず評価の高いペイントソフト「Colors!を試して見ることにした。

Colors!の設定

 Homebrewソフトは拡張子が「.nds」という形式のファイルで配布されており、通常はこれをDeSmuMEにドラッグ&ドロップすればソフトを起動できるのだが、Colors!の場合はファイルI/OにDLDIという仕組みを使用しており、先にこれの設定を行う必要がある。

 DLDIはDS用のストレージデバイス(マジコンなどと呼ばれているもの)用I/Oドライバのようなもので、使用するストレージデバイスに対応した「パッチ」を.ndsファイルに適用しておく必要がある。DeSmuMEの場合、「GBA Movie Player (Compact Flash)」を利用せよとのことなので、まずここからmpcf.dldiをダウンロードする。また、パッチを適用するためのツール「Dlditool-win32-gui」もダウンロードしておく。これはWindows用のGUIツールなので、ほかのOSを使っている場合は適当なものを選んでほしい。

 Dlditool-win32-guiを適当な場所に展開し、「dlditool32.exe」を実行すると「DLDI Patcher v0.32」というウィンドウが表示される。「DLDI File」で先ほどダウンロードしたmpcf.dldiが含まれるフォルダを選択し、「Binaries」でColors!に含まれる「HBMenu.nds」を指定する(ファイルのドラッグ&ドロップでも指定可能)。最後に「Patch」ボタンをクリックすればパッチ完了となる。

DLDI Patcherでパッチを適用

DLDI Patcherでパッチを適用

ディスクイメージファイルを作る

 さて、本来ならあとはHBMenu.ndsファイルをDeSmuMEで開き、メニューから「colors.nds」を選択すればソフトが起動するはずなのだが、DeSmuMEの問題なのかそれともDLDIの問題なのか、Colors!を起動すると「ストレージに書き込めない」という旨のメッセージが出て操作できなくなってしまう。どうもWindows側のフォルダをそのままストレージとして利用するあたりが問題のようなので、ディスクイメージファイルを作成し、ファイル一式をそこに格納することで対処することにする。

 ディスクイメージの作成・操作だが、残念ながらWindowsで動作する適切なものは発見できなかった。しょうがないのでMac OS X上でディスクイメージを作成&マウントし、そこでファイルをコピーしたあとでWindows機に転送する、という方法で対処する。

 Mac OS Xでは、「ディスクユーティリティ」を使用して簡単にディスクイメージを作成したり、マウントできる(Linuxでもルート権限があれば比較的容易に作業可能だが今回は割愛)。

Mac OS Xのディスクユーティリティでディスクイメージ作成

Mac OS Xのディスクユーティリティでディスクイメージ作成

 「フォーマット」で「MS-DOS(FAT)」を、「パーティション」で「パーティションマップなし」を選択するのがポイント。容量はお好みでどうぞ。

 ディスクイメージを作成・マウントしたら、Colors!の配布アーカイブに含まれるファイル一式をそこにコピーし、アンマウントしてディスクイメージをWindows側にコピーすれば作業完了。

ディスクイメージを使用する設定

 あとはDeSmuMEを起動し、「エミュレーション」メニューの「GBAスロット」を選択して「GBAスロット」ウィンドウを開き、「Compact Flash」を選択して作成したイメージファイルを指定すればOK。

「GBAスロット」でディスクイメージを指定

「GBAスロット」でディスクイメージを指定

 ただし、DeSmuME 0.9.6のWindows版の場合、ここで設定した値が反映されないというバグがあるので、ここでファイルを指定した後いったんDeSmuMEを終了し、DeSmuME.exeと同じディレクトリ内にある「desmume.ini」をテキストエディタで開いて次の個所を修正する必要がある。

[GBAslot.CFlash]
fileMode=1  ←「1」に設定
path=C:\work\desmume\
filename=C:\work\desmume\test.dmg

 以上でやっと全作業工程は完了。パッチを適用したHBmenu.ndsをDeSmuMEで開き、メニューから矢印キーで「Colors.nds」を選択してXキーを押せばColors!が起動する。

HBMenuからColors!を起動する

HBMenuからColors!を起動する

Colors!をDeSmuMEで実行

Colors!をDeSmuMEで実行

 ちなみに、実機で動かす場合は自動的にDLDIドライバが選択されるはずなので、パッチ作業は不要らしい(確認していないので本当かどうかは不明)。