term タグ別の記事一覧

「Pythonではバイナリファイルを操作したい時にバイナリ列をリストに変換して操作した後にまたバイナリ列に戻さなければならない」というのは不正確

 「Pythonでバイナリファイルを操作したい場合にファイルから読み出したバイナリ列をリストに変換しないとデータを自由に編集できないし、書き出し時に再度バイナリ列に変換しなければならないのでPythonは嫌い」という話を見かけたのですが、それは不正確だし適切な処理ではないです。

 そこではこの処理について、次のように記述されていました(適当に抜粋・改変しています)。

f = open("somefile.bin", "rb")
bin = f.read()
f.close()

data = []
for b in bin:
    data.append(b)

# ここでインデックスを使ったdata配列の操作を行う
# :
# :

f = open("output.bin", "wb")
f.write(bytearray(data))
f.close()

 このコードでは、データをbinという変数に読み込んだ後、dataというlistに入れて処理し、最後にそのlistをbytearray型に変換してからファイルに書き出しています。確かにこう書いてしまうと、配列への変換が冗長です。そもそもなぜこういった処理が必要になるかというと、open()でバイナリ形式での読み出し(“rb"フラグ)を指定した場合、読み出されたデータはbytes型のオブジェクトとして返されます。

$ python3
Python 3.7.5 (v3.7.5:5c02a39a0b, Oct 14 2019, 18:49:57) 
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
>>> with open('testdata.bin', 'rb') as f:
...     bin_data = f.read()
... 
>>>
>>> type(bin_data)
 <class 'bytes'>

 bytes型では、インデックスを使ってその値を取得することができます。

>>> bin_data[48]
48

 一方で、bytes型はimmutableなオブジェクトなので、値を変更することはできません。

>>> bin_data[48] = 'A'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

 しかし、実はPythonにはbytearrayというバイト列を扱うmutableな型もありまして、これを使えばbytes型と同様にインデックスを使った指定でデータを書き換えることができます。

>>> ba = bytearray(bin_data)
>>>
>>> type(ba)
 <class 'bytearray'>
>>>
>>> ba[48]
48
>>>
>>> ba[48] = 0x10
>>>
>>> ba[48]
16

 さらにこのbytearray型のオブジェクトは、write()メソッドの引数として指定することで直接ファイルに書き出せます。

>>> with open('output.bin', 'wb') as f:
...     f.write(ba)
... 
256

 ということで、先に挙げられていた「不満のあるコード」は次のように書き換えられます。

with open("somefile.bin", "rb") as f:
    data = bytearray(f.read())

# ここでインデックスを使ったdata配列の操作を行う
# :
# :

with open("output.bin", "wb") as f:
    f.write(data)

 このコードでも、ファイルから直接bytearray型のオブジェクトに読み込ませている訳ではない(bytes型オブジェクトを中継している)ため、結局バイナリ列をリストに変換しているじゃないか、と言われたらまあそうなんですが、それをバイナリ列に戻さなくても書き込みはできます、という話でした。

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

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

macOSアプリ開発でStoryboard+Core Dataを使うときのハマり話

 ふと思い立って久しぶりにmacOSアプリ開発を始めて見たのだが、Core Dataで管理しているデータを(CocoaBindingを使って) Storyboardを使って作った画面上に表示するところでハマったうえにググっても情報を探すのが大変だったのでメモ。

参考文献

作業と疑問点

 Xcode(7.2.1)でOS Xの「Cocoa Application」を選択してテンプレートからプロジェクトを作成。このとき「Use Storyboards」、「Create Document-Based Application」、「Use Core Data」にチェックを入れておく。続いてAppleのCore Dataプログラミングガイドの「管理オブジェクトモデルの作成」に従ってオブジェクトモデルを作成する。

 さらにこのドキュメントでは「Core Dataスタックの初期化」を行うよう記されていて、NSManagedObjectContext、NSPersistentStoreCoordinator、NSManagedObjectModelの3つが必要と記述されている。しかし、初期化コードとしてDataControllerクラスの実装例もあるのだが、このオブジェクトをどのオブジェクトが所持すべきなのか(ビューなのかドキュメントなのか)が記載されていない。

 プロジェクト作成時に「Use Core Data」にチェックを入れておくと、ドキュメントの基底クラスとしてNSPersistentDocumentが使われるのだが、ドキュメントを見る限り、自動的にCore Data関連の初期化を行って「ready-to-use」のNSManagedObjectContextのインスタンスを提供すると書いてある。この解釈だと、自前でCore Dataスタックの初期化をする必要はなさそうである。

 「Core Data の使い方(1)」ページではViewController内でNSManagedObjectContextのインスタンスを作成し、ここでCore Dataの初期化を行っているのだが、本来Core Dataの管理はDocument(MVCにおけるModel)の仕事であるべきで、ViewController内でこの作業を行うのはよろしくないのではないか、という疑問があるし、前述の通りNSPersistentDocumentを利用する場合この処理は不要であるはずである。

 しかし、Documentが持つNSManagedObjectのインスタンスをArray Controllerに参照させる手段が分からない。DocumentにArray Controllerのoutletを作成できれば、そのオブジェクトのsetManagedObjectContextを使ってNSManagedObjectContextインスタンスを渡すことはできるのだが、StoryboardではDocumentにArray Controllerのoutletを作成することもできない。

 ちなみにArray Controllerを使ってTable ViewとArray Controllerをバインドしただけだと、Array ControllerにNSManagedObjectContextが関連付けられていないため「cannot perform operation without a managed object context」というメッセージが表示される。

解決法

 StackOverflowの「How do you bind a storyboard view to a Core Data entity when using NSDocument?」にそのものずばりの質問と回答があった。結論的には、まずDocument.swiftのmakeWindowControllers()の末尾に下記を追加する。

windowController.contentViewController!.representedObject = windowController.document

 これでView ControllerのrepresentedObjectがDocumentを参照するようになる。

 続いてArray ControllerのView Controllerで、Managed Object Contextで「Bind to」にチェックを入れ、「View Controller」を指定する。Model Key Pathには「representedObject.managedObjectContext」を指定する。以上で完了。ただ、このときModel Key Pathに「!」が表示されるのがちょっと気持ち悪い。

まとめ

  • 「Use Core Data」を選択してプロジェクトのテンプレートを作った場合、独自に初期化コードを書く必要は無い
  • DocumentからView Controllerにアクセスするのは面倒臭い

 なおこの辺の作業を行ったコードはhttps://github.com/hylom/ttune/tree/76d43fe476c24f064aee51b7748529aa82a309d1です。

Python 2.6/2.7のxmlrpclibでxml.parsers.expat.ExpatErrorが出た場合の対処

 Python 2.6系および2/7系のxmlrpclibで、サーバーからはどう見ても正しいXMLが返ってきているはずなのにxml.parsers.expat.ExpatErrorが出た場合の対処方法メモ。

 xmlrpclibでは、サーバーから受け取ったXMLデータを一定サイズごとに分割してXMLパーサーに投入する、という処理を行っている。このときXMLパーサーにExpatを使っていると、受け取ったデータが分割される位置によっては、不正なXMLと認識されて以下のようにエラーになる模様(DebianのPython 2.6.6とMac OS XのPython 2.7.2で確認)。Expatを使わないようにするとこの問題は発生しない。

  File "/Users/hylom/repos/anpanel/myxmlrpclib.py", line 559, in feed
    self._parser.Parse(data, 0)
xml.parsers.expat.ExpatError: not well-formed (invalid token): line 30, column 114

 たぶん挙動的にExpatのバグのようだが、それを修正する気力もなかったので、とりあえずxmlrpclibでExpatを使わないようにすることで対処。たぶんパース速度は低下すると思われるが。具体的には、xmlrpclibをimportしたのち、XMLパーサーを取得するxmlrpclib.getparser関数を以下のようにして置き換える。

import xmlrpclib

# xmlrpclib's ExpatParser has bug, so do not use
org_getparser = xmlrpclib.getparser
def mygetparser(use_datetime=0):
    (p, t) = org_getparser(use_datetime)
    # if parser is ExpatParser, replace to SlowParser
    if isinstance(p, xmlrpclib.ExpatParser):
        p = xmlrpclib.SlowParser(p._target)
    return (p, t)
xmlrpclib.getparser = mygetparser

 ここでは、getparser関数の戻り値がExpatParserクラスのインスタンスだった場合、SlowParserクラスのインスタンスに置き換えるという処理をやっている。当然ながらかなりのdirty hackなので利用はおすすめしない。

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変数で指定したサイズまで、つまりバイナリの最初から最後までを取り出してコールバック関数を実行している。

Node.jsのhttp.request関数/http.get関数とタイムアウト

 Node.jsのhttpモジュールには、HTTPクライアント機能も実装されている。http.request関数およびhttp.get関数がそれだ。詳細はドキュメントを確認していただきたいが、http.requestは任意のリクエストメソッドを使ってリクエストを送信できるものだ。また、http.getはGETリクエストに限定されるものの、URLを与えるだけで簡単にリクエストを送信できる。

 たとえば以下のコード(http-request.js)は、引数で指定したURLに対しGETリクエストを送信し、取得したコンテンツを表示するものだ。

http-request.js
#!/usr/local/bin/node
var http = require('http');

// check arguments if (process.argv.length < 3) { process.exit(-1); } var targetUrl = process.argv[2]

// send request var req = http.get(targetUrl, function(res) { // output response body res.setEncoding(‘utf8’); res.on(‘data’, function(str) { console.log(str); }); });

// error handler req.on(‘error’, function(err) { console.log(“Error: " + err.message); });

 http.request関数やhttp.get関数はともに戻り値としてhttp.ClientRequest型のrequestオブジェクトを返す。http.request関数の場合、request.endメソッドを実行して明示的にリクエストの送信を完了させる必要があるが、http.get関数の場合はendメソッドは暗黙的に実行されるので不要だ。

 http-request.jsを以下のように実行すると、http://hylom.net/のコンテンツがコンソールに表示される。

$ node http-request.js http://hylom.net/

 さて、http.get関数やhttp.request関数を使ってリクエストを送信する場合、デフォルトではタイムアウト時間が設定されない。つまり、サーバーへの接続が成功した後、サーバー側がレスポンスの送信を完了しない限りクライアント側の処理は終了しない。

 たとえば、次のslowserver.jsはローカルホストの4000番ポートで待ち受けをし、リクエストを受信してから5分(300秒)後にレスポンスを返すというものだ。

slowserver.js
#!/usr/local/bin/node
var http = require('http');

server = http.createServer(); server.on(‘request’, function(req, res) { setTimeout(function() { res.setHeader(‘Content-Type’, ‘text/plain’); res.writeHead(200); res.write(‘200 - OK.'); res.end(); }, 300 * 1000); });

server.listen(4000, function() { console.log(‘start listening on port 4000…'); });

 このサーバーを実行し、先ほどのhttp-request.jsを使ってアクセスすると、実行後5分をすぎたあたりでレスポンスが表示される。

$ node slowserver.js &
start listening on port 4000...

$ node http-request.js http://localhost:4000/
200 - OK.
$ fg
node slowserver.js
^C

 さて、タイムアウトが設定されていないということは、サーバー側がレスポンスを返さない、もしくは接続を破棄しない限り、クライアント側は永遠にレスポンスを待ち続けることになる。クライアント側でタイムアウト時間を設定するには、http.get関数やhttp.request関数の戻り値として返されるhttp.ClientRequestクラスのsetTimeoutメソッドを利用する。

request.setTimeout(timeout, [callback])

 timeout引数にはタイムアウト時間を指定する。単位はミリ秒だ。接続後、ここで指定された時間が経過するとrequestオブジェクトに'timeout'イベントが発生する。callback引数はtimeoutイベント発生時に一度だけ実行されるコールバック関数を指定する。なお、'timeout'イベントハンドラは引数を取らない。

 ここで注意したいのが、timeoutイベントが発生した場合でも接続は継続されたままになっている点だ。次のコードは、先のhttp-request.jsにrequest.setTimeoutメソッドを使ってタイムアウトを設定したものだ。

http-request.js(改造版その1)
#!/usr/local/bin/node

var http = require(‘http’);

// check arguments if (process.argv.length < 3) { process.exit(-1); } var targetUrl = process.argv[2]

var req = http.get(targetUrl, function(res) { console.log(‘get response’) res.setEncoding(‘utf8’); res.on(‘data’, function(str) { console.log(str); }); }); req.setTimeout(1000);

req.on(‘timeout’, function() { console.log(‘request timed out’); req.abort() });

req.on(‘error’, function(err) { console.log(“Error: " + err.code + “, " + err.message); });

 これを実行すると、次のようにtimeoutイベントは発生しているものの、イベント発生後も引き続きレスポンスを待ち続けていることが分かる。

request timed out
200 - OK.

 timeoutイベント発生時に接続を破棄したい場合、イベントハンドラ内でrequest.abortメソッドなどを使用して明示的に接続を破棄する操作を行えばよい。

req.on('timeout', function() {
  console.log('request timed out');
  req.abort()
});

 このように修正した上で先ほどのhttp-request.jsを実行すると、次のようにタイムアウト発生時に接続が破棄され、errorイベントが発生する。

$ node http-request.js http://localhost:4000/
request timed out
Error: ECONNRESET, socket hang up

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));

プログラミングにおいて重要な知識をどこから学んだか

 「大切な事は全て.NETから学んだ」を見て、ああそういう人もいるんだなと思ったので自分がプログラミング関連知識をどこから学んだかを挙げてみる。

  • プログラミングとは何か:BASIC。
  • コンピュータのアーキテクチャ:アセンブラ(とFM-7のマニュアル)。
  • 基本的な2D画像作成:BASICとアセンブラ
  • 「関数」という仕組みとスコープ:C
  • オブジェクト指向、クラス、継承:C++
  • マルチスレッド、同期/排他処理:C/C++
  • MVCアーキテクチャ:MFC
  • イテレータ、連想配列:STL
  • ポリゴンやそれを使った3Dレンダリング:OpenGL
  • 関数型言語:emacs lisp
  • 正規表現:Perl
  • ラムダ関数:大学院のプログラミング理論の授業
  • モジュールリポジトリの利用:Perl(CPAN)
  • 「読みやすい」コードの書き方:Perl(というか書籍「Perlベストプラクティス」)
  • Webアプリケーションフレームワーク:Perl(Catalyst)
  • テンプレートエンジン:Perl(Template Toolkit)
  • メッセージ型アーキテクチャ:Objective-C
  • ダックタイピング、動的型付け、リスト内包:Python
  • 並列処理:OpenMP
  • プロトタイプベースのクラス機構、クロージャ:JavaScript

 見事にJavaとPHPとRubyからは何も学んでいない、というかこれらの言語は読めるけどあまり書けない(汗)。

SDL_mixerのspecファイル

 SDL_mixerはCentOS用のパッケージが用意されていない&提供されているSRPMをx64環境でビルドすると64ビットバイナリが/usr/lib/以下にインストールされてしまうという問題があるので、これを修正するspecファイルを作成した。Gist:SDL_mixer.specで公開している。

CentOS 5を延命させる

 CentOS 5を使っていると、含まれているソフトウェアのバージョンが古かったり、そもぞもリポジトリにそのソフトウェアがなかったりして困ることがある。さっさとCentOS 6にアップデートすればいい話なのだが、面倒くさいとか、そもそもフルバックアップしないと行けないとか色々と大変だったりするので(CentOS 5からCentOS 6へのアップデートは再インストールが推奨されている)、なんとかまだCentOS 5をこのまま使いたい、そんな時に役立つのがEPEL。

 EPELは「Extra Packages for Enterprise Linux」の略で、Fedora Projectが運営しているRed Hat Enterprise Linuxおよびその互換OS向けのパッケージリポジトリ。同種のものにrpmforgeがあるわけですが、Fedora Projectが運営しているということでEPELのほうがやや信頼感が(個人的には)高いということでこちらを使うことに。

 導入方法は簡単。以下を実行するだけ。

$ sudo rpm -Uvh http://ftp.jaist.ac.jp/pub/Linux/Fedora/epel/5/i386/epel-release-5-4.noarch.rpm

 これでgitとかpython26とかをyumでインストールできるようになります。

Windows Azureメモその2:Azure Drive

 Azure Driveを利用するコード例がいくつかWebにあるのだが、「Windows Azure アプリケーション開発入門: 第 5 回 Azure ストレージ � Storage Client API を極める」は間違っているので注意。これで1日潰れた。

 全体としては内容はほぼ正しいのだが、最後に紹介されているドライブを作成するコードは現状(Azure SDK 1.6)では正しく動かない。問題の個所は、PageBlobを作成している部分。CloudDrive

//PageBlobの作成
   pgAzure.Create(nDriveSize * 1024 * 1024);

Windows Azure 実環境上で CloudDrive の Mount メソッドが Unknown Error を発生させる」で述べられているが、CloudDriveのCreateメソッドを呼ぶ前に利用するBlobを作成していてはダメ。CloudDriveのCreateメソッドではBlobのフォーマット(VHD作成)も行っているため、CloudPageBlobのCreateメソッドでBlobを作成してしまうとマウントできなくなる模様。

Windows Azureメモその1

最近Windows Azureを触っているので備忘録的にはまったポイントをメモ(その1)。

SDKのバージョン

Windows AzureはSDKのバージョンアップとともに機能が増えたり仕様が変わっているため、古いバージョンのドキュメントを参照していると現状では仕様が変わっていたりしてハマる。特に1.3ではIIS周りの仕様変更など大きな変更点があったので、1.3未満のドキュメントを参照している場合注意が必要。特にFull IISあたり。

参考:Windows Azure 1.3の新機能の概要 - @IT

ディレクトリ構成

Windows Azureにデプロイした際、アップロードしたファイルがどのドライブに保存されるかは不定のようだ。E:の場合もあればF:の場合もある。WebRoleの場合、これらのドライブの、「¥approot¥」以下と「¥siteroot¥0¥」以下にファイルが展開される。¥approot¥と¥siteroot¥0¥以下には同じものが展開されるが、HTTPサーバーのルートとなるのは¥siteroot¥0¥のほう。

Web配置

Web配置では¥siteroot¥0¥以下が更新され、¥approot¥側は更新されない。

スタートアップタスクとその実行フォルダ

IISの設定を変更したい場合、スタートアップタスクとしてバッチファイルを実行するよう指定して処理するのが定石。スタートアップタスク実行時のカレントディレクトリは明言されていないが、Windows Azure環境では¥approot¥bin¥以下になる模様(エミュレータ上ではまた別のディレクトリになるので注意)。スタートアップタスクとして実行したいファイルのプロパティで「出力ディレクトリにコピー」を「常にコピーする」などに設定すると、そのファイルがこのディレクトリに配置される。サブディレクトリ以下にあるファイルはbin以下にそのサブディレクトリが作成されて配置される。

「ビルドアクション」の設定

ビルドアクションが「なし」のファイルはapprootおよびsiteroot以下に配置されず無視される。必要なファイルは「コンテンツ」に設定しておけばよい。

バッチファイル

Visual Studioでテキストファイルを作成するとBOM付きUTF-8エンコードのファイルができるのだが、これをバッチファイルとして実行しようとするとBOMが認識されずにエラーとなる。

エミュレータと実環境の違い

ディレクトリ配置が異なる。本番環境で¥approot¥bin¥に配置されるディレクトリは別の場所に配置される。

リモートデスクトップ

Azure SDK 1.6以降では発行時のウィザードでロールへのリモートデスクトップ接続の設定を行えるが、ここで「自動」を選択しないと、なぜかリモートデスクトップ接続もWeb配置もできなくなることがある(体験談)。前者はロールの再起動でなぜかできるようになったが、後者はリモートデスクトップが可能になった状態でもなぜかできなかった……。

あと、リモートデスクトップについてはWindows以外のクライアントもあるが、接続にウィザードで作成した証明書が必要なので、発行に用いたWindows環境以外から接続したい場合は証明書関係の設定が必要。ということでMacやLinuxから接続できるかは不明。

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

 お粗末様でした。

Pythonコード中のSQL文インデントを考える

 Pythonコード内にSQL文を書くときどうすれば良いのか、いまいち答えが探せなかったのでググって見た話。

 ちなみに、今までは下記のような感じのコードを書いていた訳だが、これ見るからに分かりにくい。

# コード例その1
        cur.execute("""
          create table count (
            sid text,
            count int);""")

# コード例その2
        try:
            cur.execute("""insert into count ( sid, count )
                           values ( :sid, :count );""", d)
        except sqlite3.IntegrityError:
            cur.execute("""update count set sid = :sid, count = : count
                           where sid = :sid;""", d)
# コード例その3
        cmd = """select sid, title, date from stories where date >= ? and date < ? and sid in (
                 select sid from topics where topic == ? and sid in (
                 select sid from topics where topic == ? )) order by date
        """
        cur.execute(cmd, (begin_t, end_t, t1, t2))

 「SQL インデント」でググると、そういう話のネタが一杯出てくるでてくる。その中から拾ってみたのが下記。

 異端だけど、俺的コーディングルール SQL編 – suVeneのアレというのも参考になった。

 で、この辺をまとめたところ、だいたい以下のようなルールに落ち着いた。

  • SQLキーワードは大文字で
  • 括弧挟まれた部分はインデントレベルを+1する
  • カンマ、ANDの直後で改行
  • カンマやANDでつなげられている部分はなるべくキーワード部分でそろえる

 このルールで書いたコードは下記のような感じ。

# コード例その1
        cur.execute("""
            CREATE TABLE count (
                sid text,
                count int
            );
        """)

# コード例その2
        try:
            cur.execute("""
                INSERT INTO count (
                     sid,
                     count
                )
                VALUES (
                    :sid,
                    :count
                )
            """, d)
        except sqlite3.IntegrityError:
            cur.execute("""
                UPDATE count
                SET sid = :sid,
                    count = : count
                WHERE sid = :sid
            """, d)
# コード例その3
        cmd = """
            SELECT sid,
                   title,
                   date
            FROM stories
            WHERE date >= ? AND
                  date < ? AND
                  sid IN (
                      SELECT sid 
                      FROM topics
                      WHERE topic == ? AND
                      sid IN (
                          SELECT sid
                          FROM topics
                          WHERE topic == ?
                      )
                  )
            ORDER BY DATE
        """
        cur.execute(cmd, (begin_t, end_t, t1, t2))

 

 本当にこれで良いのかはまだ自信がないが、おおむね間違ってはいないと思う。ていうかコード内にSQL文を直書きせずO/Rマッパー使え、という話もあるが……。

要ログインのサイトにPythonのurllib2でアクセスする

 ログインが必要なWebサイトに対してスクリプトでページを取得/送信したい場合、まずログイン用のURLに対しログイン情報をPOSTしてCookieを取得する、という作業を行うのが一般的だ。しかし、Pythonのurllib2を利用すると簡単にPOSTは行えるのだが、なぜかCookieを取得できない、という問題が発生することがある。

 これはurllib2のurlopen()でURLを開き、帰ってきたオブジェクトのinfo()メソッド経由でSet-Cookieヘッダを取得しようとする場合に発生する。たとえばCMSを使用しているWebサイトで、管理用ページ以外に(正当な)Cookieを持たずにアクセスするとCookieなしでアクセスできるトップページ等にリダイレクトされる、というケース。多くのCMSではログインに成功すると管理ページトップにリダイレクトされるのだが、urllib2のデフォルト設定ではCookie処理を行ってくれない&リダイレクトを自動的に処理してくれるため、「ログイン成功→(Cookieを返すがurllib2はCookieを保存せず)→管理ページトップにリダイレクト→Cookieがないので公開ページのトップにリダイレクト→Cookieは受け取れず」という事態になってしまうことがある。

 この場合、urllib2.HTTPCookieProcessorを使ってCookie処理を行えばよいのだが、なぜかurllib2のドキュメントにはこのクラスの解説が無い。例にも上がっていない。ということで途方にくれるわけだが、実はCookieを扱うcookielibのほうに使い方の例が載っていたりする。

import cookielib
# : 
# (このへんでurlやencoded_data、headersを準備)
# :
req = urllib2.Request(url, encoded_data, headers)
cj = cookielib.CookieJar()
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
resp = opener.open(req)

 取得したCookieはCookieJarオブジェクトに保存されるので、必要に応じて適宜取り出せばOK。かわりにFileCookieJarオブジェクトを使えばファイルへのsave/loadも可能。

gitメモ:ブランチを切ってないのにやばいコードを書いちゃった場合

 gitを使ってコードの管理をしている場合において、実験的なコードを書く場合はソースコードを編集する前にブランチを作成しておくのが基本だ。しかし、ついブランチを作成し忘れたままでコードを変更してしまった、というパターンがある。

 この場合、下記のようにgit stashでいったん変更点を保存した上で直前のcommitに戻し、新たなブランチを作成してそこでgit stash applyを行えばよい。

$ git stash
$ git checkout hogehoge -b
$ git stash apply
$ git add hogehoge foobar
$ git commit

 git stash、便利だ。

Small Basic 0.9リリース

 Microsoftがプログラミング学習用としてリリースしているBASICプログラミング環境「Small Basic」の0.9が6月11日にリリースされた。

 Small Basicブログによると、Small Basicコンパイラのパフォーマンス強化や新規ライブラリの追加などが行われているとのこと。ループが多いプログラムで、4倍ものパフォーマンス向上を達成できた例もあるそうだ。

 追加されたライブラリでは、MML的記法で音楽を再生できる「PlayMusic」機能が面白そうだ。教育用とはいえ、Flickr連携や作成したプログラムをSilverlightを用いてWebブラウザ上で実行する機能などもあり、ちょっとしたユーティリティなどを作成するのにも便利かもしれない。

Pythonのwith構文と__enter__、__exit__

 Pythonのwith構文がいまいち掴めなかったので、ざっとまとめてみた(いまさらながら)。ドキュメントはPython リファレンスマニュアルの7.5 with 文にある。

 withを使ったコード例は、下記のような感じ。

c = ClassHogeHoge()
with c:
    c.foobar()

 上記のコードは、下記と等価となる。

c = ClassHogeHoge()
c.__enter__()
c.foobar()
c.__exit__()

 つまり、withに続くインデントブロックを実行する前に指定したオブジェクトの「__enter__()」メソッドを呼び出し、実行後に「__exit__()」メソッドが暗に呼び出される、という仕組み。

 __enter__()と__exit__()の定義は、Python リファレンスマニュアルの3.4.9 with文とコンテキストマネージャにある。__enter__()の引数はselfのみだが、__exit__()はself、exc_type、exc_value、tracebackの4つの引数をとる。withに続くインデントブロックが正常に実行された(つまり、例外が送出されなかった)場合、(self以外の)引数にはNoneが与えられる。なにか例外が発生した場合、その例外に関する情報が与えられるらしい。

 また、「with hogehoge as foo:」のような形でwith文を利用する場合、__enter__()の戻り値がfooに代入される。__exit__()の戻り値は例外処理の伝搬制御に使われ、Falseの場合例外が発生た場合でも例外を伝搬させず、Trueを返すと例外が伝搬されるとのこと。

 下記、使用例。

class CacheDB(object):
    DB_FILE = "database/db_dat"

    def __init__(self):
        self.con = None
        self.cur = None

    def __enter__(self):
        self.con = sqlite3.connect(self.DB_FILE)
        self.cur = self.con.cursor()

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type:
            self.con = None
            self.cur = None
            return False
        self.con.commit()
        self.cur.close()
        self.con.close()
        self.con = None
        self.cur = None
        return True

    def add(self, foo, bar, hoge):
        try:
            self.cur.execute("""insert into data ( foo, bar, hoge )
                           values (?, ?, ?);""", (foo, bar, hoge))

def main():
    usage = "%s logfile" % sys.argv[0]
    db = CacheDB()
    try:
        fname = args[0]
    except IndexError:
        sys.exit(usage)

    f = open(fname, "r")
    with db:
        for l in f:
            term = l.strip().decode("utf-8").rsplit("\t", 3)
            db.add(foo=term[0],
                   bar=term[1],
                   hoge=term[2])

if __name__ == '__main__':
    main()

 タブ区切りのデータファイルを1行ずつ読んでデータベースに突込む、という処理。withを使うことで、データベースアクセスの準備→データ挿入→コミットという流れをきれいに実装できました。

Mako Templaters for Pythonメモ1:Makoってなに?

 最近Pythonのテンプレートエンジン「Mako」を触ってるんだけど、日本語の情報が全然ないのでまとめてみる。

Mako公式Webサイト

Mako公式Webサイト

Makoは「Hyperfast and lightweight templating for the Python platform.」(Pythonプラットフォーム向けの超高速で軽量なテンプレートエンジン)だ。Pythonのテンプレートエンジンとしては、Python標準ライブラリに含まれているstring.Templateや、WebフレームワークのDjangoに組み込まれているDjangoテンプレートエンジン、そしてCheetahなどが知られているが、Makoはそれらよりも高速で、テンプレート内にPythonコードを埋め込む機能や、キャッシュ機構などを備えてるのが特徴だ。また、文法もPython風であり習得しやすいのも利点だろう。

 いっぽう、特に大きな欠点は(いまのところ)見つかっていないのだが、日本語環境で利用する場合は文字コードをうまく扱うように適切にオプションを与える必要がある。

 ちなみに、MakoのWebサイトにはPython向けテンプレートエンジンのパフォーマンス比較が掲載されているのだが、ほかのテンプレートエンジンと比べてMakoは同等レベル以上に高速、という結果が出ているようだ。

Makoのインストール

 Makoはダウンロードページから行える。配布されているtar.gz形式のソースコードをダウンロードしてインストールできるほか、Pythonモジュール用のインストールマネージャ「easy_install」を利用してもインストールできる。

 ソースコードからインストールする場合は、ダウンロードしたアーカイブを展開し、次のように実行する。

# python setup.py install

 ちなみに、makoはすべてPythonコードで記述されているため、基本的にはPythonが動く環境であればどのプラットフォームでも動作するはずだ。

 つづく。

gitメモ:diffとやり直し

 最近久しぶりにgitを触って色々と忘れていたので再度メモ。

mergeを取り消す

 mergeを実行したらconflictが大量に出てしまったので取り消したい、という場合、下記を実行。

$ git reset --hard ORIG_HEAD

conflictを解決する

 gitにはmergeを実行した場合にconflictを解決するコマンド「git mergetool」がある。conflictしているファイルに対して、順番にdiffツールを実行して編集を促すもの。しかし、Windows上のMSysGit環境で実行したら見事にvimのdiffが実行されたので個人的にはデフォルトでは使えない認定(自分はvimはあまり使えないので)。

$ git mergetool

 .gitconfigの「merge」および「mergetool」項目で起動するツールを設定できるそうなので、今度はEmacsに設定してテストしてみようかな。

diffを使う

 マニュアルに書いてあるけど、「git diff」コマンドの書式は下記のとおり。

git diff <比較元commit> <比較先commit> [<対象ファイルパス>]

 ここで、比較元・比較先commitはハッシュだけでなく、「HEAD」(最新のコミット)や「HEAD^」(最新の1つ前のコミット)、「HEAD^^」(最新の2つ前のコミット)、「HEAD~4」(最新の4つ前のコミット)などのほか、tagも利用可能。

 例えば最新のコミットと、その1つ前のコミットでdiffを取るには次のようにする。

$ git diff HEAD^ HEAD hogehoge.py

PythonでCGI経由でファイルアップロード

 Webベースで記事作ったりサイトデザインしたりしていると、多量のファイルをアップロードする機会も多々あるのでファイルを自動アップロードするスクリプトを書きたい、という話。

 とりあえずググったら「 残高照会メモ: pythonでアップロード 」が出てきたのだが、自前でMIMEエンコーディングしなきゃいけないのがちょっとアレだ。

 ちなみにPerlだと下記のような感じでいける。

sub post_attachment {
    my $self = shift @_;
    my %args = @_;

    my $file_name = $args{file};  # filename

    my $url = "アップロード先URL";
    my $ua = LWP::UserAgent->new( 'agent' => "適当なUserAgent文字列", );
    my $req = HTTP::Request::Common::POST $url, Content_Type => 'form-data',
    Content => [
				file_content => ["$file_name"],
				description => "",
				op => "addFileForStory",   # form "op"の値
				sid => $sid,               # form "sid"の値
				Submit => "Submit",        # form "Submit"の値
	];

    my $resp = $ua->request( $req );

    if( $resp->is_error ) {
	return 0; #print "upload $file_name: failed.\n";
    } else {
	return 1; #print "upload $file_name: succeed.\n";
    }
}

 これをPythonでやりたい。ということで続く。