node-gypそしてnanへ。おまけでTypeScript

github.com

nodeのアドオンを調べてたら本もなければ情報も少なく、この土日ずっと家族には仕事が忙しくてヤバいという体裁を装い篭ってたので、せめて記録ぐらいは残そうとサンプルを書きました。サンプルは超シンプルになりましたがこれは理解してからシンプルに書き直した結果で、元は何倍ものゴミクズです。

承前

  • nodeだけではできないことをやる。
    • 動画や画像の出力や解析はゼロから書くのは無理ゲーだけど良いライブラリが既にある
    • OSにインストールされているフォントを取得して使う。更に出力サイズはきっちり計る
  • パフォーマンス
    • 極重な処理をハイパーにこなす

既存のコマンドをシェルで叩き標準入出力やファイルシステムでツールをチェーンするのは手軽に実現できます。これは手元環境だけでなく、AWS Lambdaとかのサーバレス環境も要はDockerなのでこの方法は有効です。魔法を見て驚いて中身見たらexecやspawnかよとズッコケることたまにある。

無い物はシェルコマンドをGoやSwiftででも作れば良いのでしょう。そのほうがUNIX的な思考だし再利用もしやすい。でも、なんかズッコケるんだよね。燃えない。TypeScriptで楽に書きたいのに、nodeで無理なところ探してアドオン書こうというのは、やってみたかったという初めから答えあってのことです。手間を考えたらあんまり正解じゃなかった気もするけど、ギリギリ日曜の夜にはできるようになったからOK。

nodeのアドオンを作る

nodeのアドオンはnode-gypを使って作るのが便利なようです。これはマルチプラットフォーム開発環境を提供してくれて、プラットフォーム毎にMakefile等を調整してくれる。しかもnpmと組み合わせて利用者の手元でOSとnodeのバージョンに応じてコンパイルすることができるので配布が柔軟になります。あらかじめよくあるOSとnodeの組み合わせについてバイナリを用意しておくためには、node-pre-gypというのもあります。これはAWSに提供側ビルドをホストしておくことがすぐできる。

nodeのアドオンというのは結局はそのJSインタープリタであるV8の拡張なのですが、こいつが後方互換軽視なアグレッシブな姿勢で作られているらしく、つられてnodeもアグレッシブにならざるを得ない様子なので、過去からの互換性を維持するための抽象層がマクロとC++テンプレートで提供されています。それがNan。nodeアドオンの作りかたでググったら全く違ういろんな書き方が出てきます。これは記事が書かれた時期によるV8のAPI変化であり、書き手それぞれのNanの適用範囲の差であり、中にはNanを使っていない例も多いためでした。よってスマートかつモダンにnode-gypでV8かつフルNanなプログラミングをモノにしましょうという算段です。

ざっくりとした手順

実際はサンプル見てもらうことにして、ざっくりと手順をまとめると以下の感じ。

  • nodeのプロジェクトを作る。フォルダにpackage.jsonがあることが最低。
  • nodo-gyp、nanを用意する。node-gypはグローバルに入れるのが大勢のように思われる。
  • binding.gypを書く。これはnode-gypの唯一設定ファイル。
    • Nanの利用はおきまりのフレーズをここに書く。
    • 驚いたのはnodeのシェル実行でrequireを用いていた。node -e "require('nan')"
  • C++ソースコードを書く。V8とNanを利用。
  • package.scriptsに、"{ install": "node-gyp rebuild }" を追加する。
    • 開発時のビルドはこれをnpmやyarnで実行する
    • 公開後は利用者がnpm installないしyarn addしたと同時にビルドが走る
    • ちなみにnode-gyp rebuildはnode-gyp clean && node-gyp configure && node-gyp build
  • マルチプラットフォームはbinding.gypにうまく書ける仕組みがある。サンプルでは利用してない。
  • 既存ライブラリのリンクはbinding.gypにうまく書ける仕組みがある。サンプルでは利用してない。

Nan::AsyncWorker

サンプルでは、TypeScriptコードから文字列の引数とコールバックをアドオンに送る->アドオンは文字列の引数を保存して作業を行い(サンプルでは何も作業していないけど)、結果がOKだったら非同期にコールバックに返す -> コールバックのTypeScriptコードが実行される、という段取りになります。

ポイントはNan::AsyncWorkerがlibuvをラップしていて別プロセスを立ち上げていることです。ハイパーな処理はメインに迷惑かけずにここでやってくれという。サンプルのような軽い処理だったら本当は同プロセス同期実行でいいですね。その上でさらなるポイントは非同期に入る前に文字列を保存しているところです。V8の環境では常にガベージコレクションが走っていて、メモリの解放はAPIの中で強力にサポートされているので、別プロセス非同期実行だと解放された後にメモリアクセスしてしまいます。ここが私の週末の最後の壁で何度もクラッシュさせてました。今はstrcpyでコピーしているけど多分V8のAPIの中にうまい書き方が隠れていそう。 *1

Nan::New<String>("Hello ").ToLocalChecked()

頻出イディオムとして上記のようなコードを書くことになりますが、Nan::Newでv8::MaybeLocalというクラスを作り、ToLocalCheckedでガベージコレクションの対象となるように登録します。

TypeScript

ここまでの論点でTypeScriptは全く関係ないです。全編C++。利用のコツとしては、bindingsなるライブラリを使うことです。const binding = require('bindings')(<binding.gypのtarget_name>); bindingsはバイナリの位置を探します。

const binding = require('bindings')('helloLib');

export const greetingPromise = (name: string): Promise<string> => {
    return new Promise<string>((resolve) => {
        binding.nativeHello(name, (msg: string) => {
            resolve(msg);
        });
    });
};

こんな感じで、最近流行りのPromise化も可能。

import { greeting, greetingPromise } from '../hello';

test('greetingPromise', async () => {
    console.warn(await greetingPromise('Promise World!'));
});

こんな感じにasync-awaitで書けます。素敵。

気づき

  • V8はガベージコレクションをやるんだなということを思い出せばかなり光が射してくる
  • コールバックの有無と同一プロセス/別プロセスは違うこと
  • Nanは常に何かを楽にしてくれようとしてる。彼の親切心が何処にあるのかを探せば真実が見える

25年ぶりのC++、そして新たな問題

対象言語としてC++ 11が必須となっています。で、25年ぶりだから遠い記憶のそれは当然C++ 11じゃないんですよ。私の経験にあるのは学生の時に16bitのVC++でした。その後すぐにDelphiで更にはJava時代がきちゃう。初見はどこまでがマクロでどこから何のAPIで、言語仕様的なものはどこなのかわかりませんでした。結局Nanマクロが黒魔術であり、私が単にC++を忘れていただけでC++ 11的な何かというのは少なかったのですが。。。

ところでそもそも何でこんなことをしているかというと、まずはC/C++で書かれた魔法的ライブラリ達を取り込むためですが、次いではOSにアクセスするためです。しかしそのOSとは私の場合はOSXなのです。これが問題。OSXは基本的にObjective-Cであり、近年Swiftで代替されてつつあるわけです。C++からSwiftをイージーに呼べればアドオンのベースだけC++で書いてあとはSwiftで良かったのだけど、結構調べましたがC++からSwift(SwiftからC++じゃなく)をうまく使う方法がわからなかったので「おいおい、この年でObjective-Cかよ。俺はもう45才だぞ」と暗い気持ちになりました。Objective-CiPhoneの初期のころにいくらかやりましたが、なんかしっくり来なかったのでちゃんと習得しなかった。老い先短いので、同じく老い先短いObjective-Cは避けたい。その点でC++はいつまでも老いずに良いね。

更に調べると、CoreGraphicsやCoreTextといった一部のフレームワークC++でOKなよう。でもC++版のガイドやサンプルは当然皆無。OSXOSSじゃないからヘッダの先はブラックボックスなので、ヘッダを読んでObjective-C版のドキュメントやサンプルを参考にするしかない。ま、OSXの範囲でやりたいことは幸いにCoreGraphicsやCoreTextに集約されているようなのでボチボチやります。

*1:隠れてた。AsyncWorkerのSaveToPersistentとGetFromPersistentを使う

node v10.1.0は回復

node v10.1.0がリリースされていたので、nで切り替えて一通りテストやビルドを動かしてみました。

(node:26203) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.

ほぼ全てと言っていいぐらいに頻繁にBufferコンストラクタのワーニングが出ますけど、私の手元ではv10.0.0の時が嘘のように全てきちんと動きます。10.1.0でいいですね。

node-canvasの型定義備忘

node-canvasの型定義を使うところだけ書いてみたけど目的に敵わなかったので、定義だけ備忘。いつか使うかも。

declare module 'canvas' {
    import * as Stream from 'stream';

    interface PNGOptions {
        palette: Uint8ClampedArray;
        backgroundIndex?: number;
    }

    interface JPEGOptions {
        bufsize?: number;
        quality?: number;
        progressive?: boolean;
        disableChromaSubsampling?: boolean;
    }

    interface Canvas extends HTMLCanvasElement {
        inspect(): string;
        pngStream(options?: PNGOptions): Stream.Readable;
        jpegStream(options?: JPEGOptions): Stream.Readable;
        // pdfStream(): Stream.Readable;
    }

    interface Image extends HTMLImageElement {
        inspect(): string;
    }

    interface FontFace {
        family: string;
        weight?: string;
        style?: string;
    }

    function registerFont(src: string, fontFace: FontFace): any;
    function createCanvas(width: number, height: number, type?: any): Canvas;
    // function loadImage(src: string | Buffer): Promise<Image>;
}

v2.0.0-alpha12でregisterFontしてcreateCanvasで作ったヘッドレスCanvasに描画してpngStream/jpegStreamで書き出す一連の流れは問題ないが、loadImageで爆死。pdfStreamも不調。

@types/nodeは治った

@types/nodeの10.0.4が出てたので試しにupgradeしてみたらTypeScriptのコアLibとぶつかる問題は解決していました。nにまだ10.0.0より後は来てないので本体入れ替えないから定義だけupgradeの意味はないけど、害もなし。

node v10.0.0は地雷

node v10.0.0は地雷バージョンでした。すでに@types/nodeがv10.0.0でTypeScriptのコアLibとURLなどいくつかの型定義が被って爆死という案件がありましたが、今回はts-jestの環境で以下のテストが沈黙してしまうという問題を抱えています。非TypeScriptな素のjest環境なら大丈夫なので辛い。Babel挟むのは試してません。

describe('silence', () => {
    test('error', () => {
        fail('silent');
    });
});

これだけでテストランナーが沈黙のまま吹っ飛ぶ。これを $sudo n 9.11.1 と前のバージョンに切り替えるとts-jestでもしっかり動きます。v10.0.0はTypeScriptクラスタは回避で。APIがPromise多用で近代的に書き換わったらしいが、普通にSPAやWEBサーバ書いてる限りには関係ないし。

偶数バージョンで根源的な不都合が出ると戸惑っちゃう。。。

  • node環境構築
    • brewインストール
    • nインストール: brewによる
    • nodeインストール: nによる
    • yarnインストール: brewにより--without-nodeスイッチ付き

この構成が正義。不用意に$ brew upgrade でnodeのバージョンが変わらないし、nで適宜バージョンを変更して試せる。

開発ガイドラインとGetting started

内向きに開発ガイドラインとGetting Startedを兼ねたようなものを書こうとしたが、目次だけ書き出したけどこれでも環境作るところだけ、この後にコーディング編とテスト編とリポジトリ&CI&デプロイ編があって、そもそもコーディング編にはSPA章とサーバレス章と自前WEBサーバを含むローカルツール章があり、さらにSPA章には画面出すところまでとFluxとコンポーネントルーターAPI通信とイベントハンドリングがあり、フォーム&バリデーションやi18nやレスポンシブがある。。。さらに続く。 いつか暇になって、かつ書いたら効果が高い時にもっと目次も練って書こうと思う。おそらくそんな時は来ないだろうとも思いつつ。

  • node環境構築
    • brewインストール
    • nインストール: brewによる
    • nodeインストール: nによる
    • yarnインストール: brewにより--without-nodeスイッチ付き
  • プロジェクトの作成
    • 空フォルダにpackage.jsonを作る
    • ソースフォルダと出力先フォルダを決める
    • もしくはcliツールの利用: create-react-app、vue-cli、serverlessなど
  • package.jsonの基本的な書き方
    • プロジェクト設定: version、private、license
    • yarn addおよび-Dスイッチ付き
  • トランスパイルとリントの設定
    • yarn add typescript tslint tslint-config-airbnb -D
    • tsconfig.jsonの書き方
      • compilerOptions.target
      • compilerOptions.module
      • compilerOptions.outDir: 出力先フォルダ
      • compilerOptions.strict
      • compilerOptions.libおよびcompilerOptions.typesの意味
      • include: ソースフォルダ
      • exclude
      • compilerOptions.baseUrlとcompilerOptions.pathsの意味
    • tslint.jsonの書き方
      • extends: [tslint-config-airbnb]
      • rulesの意味
      • おまけ:ソースインラインでリント設定を抑制する方法
  • (SPAであれば)バンドラーの設定
    • cliでプロジェクトを作った場合
      • create-react-appの場合
      • vue-cliの場合
    • yarn add webpack webpack-dev-server awesome-typescript-loader tslint-loader -D
    • ルールの書き方
    • DevServerの設定
  • (SPAであれば) WEBブラウザ設定
  • テストツールの設定
    • yarn add jest ts-jest @types/jest @types/node -D
    • package.jsonにjest追加
      • moduleFileExtensions
      • testMatch
      • transform
  • package.jsonでのscriptsの書き方
    • node実行
    • ライブラリのbin
      • tsc
      • jest
      • webpack
    • シェルコマンド
    • rimrafなどの定番ツール
    • 自作JS
  • 環境の維持
    • brewでのupgradeとcleanup
    • nでのアップデートと旧版への切り替えおよび削除
    • yarn upgradeとyarn remove

考察

しかし考えるに、チェック項目として目次だけ書くのも有用かもしれない。その点で目次ツリーの大きさを抑制するためには以下の分岐をまず決める事なんだろう。

  • プラットフォーム:node(python、go)
  • node -> 言語:TypeScript(node、Babel)
  • TypeScript -> 対象:SPA(サーバレス、ローカル)
  • SPA -> フロントエンド:Vue(React、Angular)

でTypeScriptまで決めておけば先の目次みたいな感じになりますな。

gulp v4によるフルTypeScriptなビルド環境

SPAを作るには、webpack等の、色々寄せ集めて一つのJSにバンドルしてくれるツールを用いてビルドしますが、サーバサイドやローカル実行ツールのプロジェクトはそもそもビルドする必要すらありません。しかし、何らかの都合でビルドをしたい場合もあるでしょう。

  • JS以外のファイルをコンパイルしたい
    • TypeScriptで書いてる
    • SCSSとかコンパイルする
    • ドキュメントを生成する
    • テストは除いてリリースビルドを作る
  • ファイルをコピーしたり、ダウンロードしたりして構成を整える
    • フォントファイル!私はこれ
  • 難読化する

自分にニーズが無い時にはピンと来ないのですが、必要になったらどうにかしないといけない。そこでGulp、そしてv4。

https://gulpjs.com/

聞くところによると3年の沈黙を経て、昨年末ギリギリにv4.0.0がリリースされたそうです。gulp使ったことあるのは3年ぐらいは前だったかも。後方互換を保ちつつも、イマドキ風に生まれ変わりました。そしてこれがまたTypeScriptと相性が良いことが判明。

$ yarn add gulp@next gulp-typescript typescript ts-node @types/gulp @types/node@9 del -D

まず関連パッケージをインストールしますが、この時にgulpは@next、@types/nodeは@9とバージョン明示しないと本日時点ではNG。gulpはlatestがまだ3系なのは良いとして、@types/nodeの本日時点のlatestである10.0.0はTypeScriptをコンパイルできない。理由は@types/node@10.0.0とTypeScript本体のlib.d.tsでURLなど一部宣言が被って爆死しちゃうから。いずれ@types/nodeが治すでしょうけど、今日時点では@types/node@9で、治れば @types/gulp -> @types/vinyl-fs: "" -> @types/node: "" とバージョン指定してない連鎖のための10.0.0混入だからインストールする必要もなくなる。

@types/gulpは勇み足なのか本体がまだ3系をメインにしているのに普通に4系のものが入る。

// script/build.ts
import { src, dest, symlink } from 'gulp';
import * as ts from 'gulp-typescript';
const del = require('del');

(function () {
    // クリーンアップ
    del('lib');

    // コンパイル
    src(['src/**/*.ts', '!src/**/__tests__/*.ts'])
        .pipe(ts({
            target: 'es6',
            module: 'commonjs',
            strict: true,
            lib: ['es6', 'dom'],
        }))
        .pipe(dest('lib'));

    // 小サイズファイルコピー
    src('src/**/*.txt').pipe(dest('lib'));

    // 大サイズファイルはリンク
    src('src/**/*.otf').pipe(symlink('lib'));
})();

ビルドファイルは伝統のgulpfile.jsではなく何でも良いので適当な名前の.tsを作ります。ここではbuild.tsにしました。v4でgulp.taskを使わなくてよくなったのでこんな感じにかけます。プラグインもこれまでのものが使えます。ts-node入れてるのに.tsをビルドしててパッと見は奇妙なのですが、これが必要な時もある。

// package.json
{
    "scripts": {
        "build": "ts-node script/build.ts"
    },
    "devDependencies": {
        "@types/gulp": "^4.0.5",
        "@types/node": "9",
        "del": "^3.0.0",
        "gulp": "^4.0.0",
        "gulp-typescript": "^4.0.2",
        "ts-node": "^6.0.1",
        "typescript": "^2.8.3"
    }
}

package.jsonのscriptで、ts-nodeの呼び出しを書きます。以上。プロジェクトルートにscriptディレクトリを作ってそこにコマンドを一つづつファイルで書いて置いておけば良いし、共通のものなんかは関数エキスポートしてどっかでまとめて取り込んで組み立てればいいし。実行は当然以下の通り。

$ yarn build

すげー簡単。超素敵。。。だが冷静にGulpの使い所ってどれだけあるんだろうか。さらにこのぐらいのことを書くのにほとんど似たような素のnode.jsでかけるのに余計なものを詰め込んでまでTypeScriptが要るだろうか?

課題

gulp-typescriptが、オプションでpathsが効かないなど、本家tscと挙動が違う。

結論

gulpじゃなくてもいいや。

// package.json
{
    "scripts": {
        "build": "rimraf lib && tsc -p tsconfig.prod.json && cp src/font/*.otf lib/font",
        "lint": "tslint --project ./ './src/**/*.ts'",
        "test": "jest"
    }
}

元に戻った。例えばこんなこともしてます。

const _ = require('lodash');
const fs = require('fs');

(function () {
    const packageJson = _.omit(
        require('./package.json'),
        ['scripts', 'jest', 'devDependencies'],
    );
    _.set(packageJson, 'bin.helloworld', 'index.js');
    if (fs.existsSync('dist/package.json')) {
        fs.unlinkSync('dist/package.json');
    }
    fs.writeFileSync('dist/package.json', JSON.stringify(packageJson, null, 4));
})();

これは素のnode.jsなスクリプト。ちょっとビルドを整えるぐらいだったらこれでも不自由ないかとも思う。Gulpの良さはgulp.srcで対象ファイルをかき集めてきて、gulp.destでそのまま書き出せることでしょうかね。他に複雑なことをするのであれば、ファイルコピーだけでもgulpの方がわかりやすく便利なので出番が出てきます。複雑なことをするかですね。