hnwの日記

既存のCommonJSモジュールを継承して自分好みのモジュールを作る

私は最近Node.jsで趣味のスクリプトを書いています。Node.jsであれば最新のECMAScriptの文法が使えるので、その意味で勉強になって良いですね。

また、何をするにしてもnpmで複数の選択肢が見つかること、それらのモジュールを簡単に試せること、といった点はやはり便利です。私も複数のモジュールを利用して便利にコードを書いています。

ところで、こうしたモジュールを継承して自分好みの挙動に修正したり新しいメソッドを足したりしたい場合にどうすれば良いのでしょうか。これは私のようなECMAScript初心者には難しい問題で、試行錯誤にかなり時間を使ってしまいました。

本稿では、既存のCommonJSモジュールを継承して新たなモジュールを作る方法について紹介します。あくまで私なりの結論ですので、ツッコミをお待ちしております。

CommonJSモジュールとは

まずは前提知識について簡単に紹介します。CommonJSモジュールというのは、主にNode.jsで利用されているソースコード分割の仕組みです。Nodeを使っていると下記のようなコードをよく見ると思いますが、このように require で読み込むモジュールがCommonJSモジュールです。

const puppeteer = require('puppeteer');

CommonJSモジュールではrequireで返す値だけが公開され、それ以外の値は隠蔽されるので、グローバルオブジェクトの汚染で悩まされずにすむわけです。

requireでは何を返しても構いません。実際、ES6クラスを返すモジュールもあれば、普通の関数やオブジェクトを返すモジュールもあります。

ケース1a: ES6クラスの継承(トップレベル)

では、CommonJSモジュールをどう継承するか考えていきます。

まずはrequireでES6クラスが帰ってくる場合を考えてみましょう。この場合は素直に継承を実現できます。

const Foo = require('foo');
class Newfoo extends Foo {
    // 既存のメソッドを上書きしたり、新たなメソッドを追加したり
}
module.exports = Newfoo;

このように自分好みのクラスを作って、それをmodule.exportsにセットすれば既存のモジュールと同じように呼び出して使うことができます。

ケース1b: ES6クラスの継承(セカンドレベル以下)

ケース1aのクラス継承はトップレベルのES6クラスを置き換えたいときにしか使えません。下位のクラスの挙動を変更したい場合は別の方法をとることになります。

具体的な例を紹介します。以下はpuppeteerのElementHandleクラスに新たなメソッドを足す例です。

const {ElementHandle} = require('puppeteer/lib/api');

// via: https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom/21696585
ElementHandle.prototype.isVisible = async function () {
  return await this.executionContext().evaluate(el => {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    if (!style) return false;
    if (style.display === 'none') return false;
    if (style.visibility !== 'visible') return false;
    if (style.opacity < 0.1) return false;
    const bndRect = el.getBoundingClientRect();
    if (el.offsetWidth + el.offsetHeight + bndRect.height + bndRect.width === 0) {
        return false;
    }
    return true;
  }, this);
};

const myPuppeteer = require('puppeteer');

// (本稿では省略)

module.exports = myPuppeteer;

ここで書き換えているElementHandleはメソッド呼び出しを3回くらいしてようやく登場するクラスですので、クラス継承で挙動を変更するのは非現実的です。

このような場合、通常なら直接requireしない下位のクラスを取り出してプロトタイプ継承で書き換えることができます。

ちなみに、prototypeに与える関数はアロー関数で書いてはいけません。上の例のようにfunctionキーワードを使う必要があります。というのも、アロー関数で書くとthisがグローバルオブジェクトを指してしまい、メソッド呼び出しとして動かなくなってしまうのです(参考:Prototypeの関数でアロー関数は使わない - Qiita)。

ケース2: モジュールパターンの書き換え

ところで、CommonJSモジュールの多くはケース1aに当てはまりません。私の知る限り、トップレベルのオブジェクトとしてファクトリメソッドを含んだオブジェクトを返すものが多いように思います。

たとえばlog4jsなどは次のように使います。

const logger = require('log4js').getLogger();
logger.level = 'debug';
logger.debug("Some debug messages");

この場合ケース1の方法で挙動を書き換えるわけにはいきません1。このような場合は必要に応じてオブジェクトを上書きすることになります。

下記は私がlog4jsを上書きして自分好みのデフォルト挙動で使えるようにしたモジュールの抜粋です。

const log4js = require('log4js');
// オリジナルをコピーして使う。ディープコピーが必要な場合はよしなに。
const myLog4js = Object.assign({}, log4js);

// 自分好みのデフォルト値を設定する新規メソッド(本稿では省略)

const origGetLogger = myLog4js.getLogger;
myLog4js.getLogger = function (...args) {
  if (!enabled && !process.env.LOG4JS_CONFIG && defaults['appenders']) {
    configure(defaults);
  }
  return origGetLogger.apply(myLog4js, args);
}

module.exports = myLog4js;

このパターンでの書き換え対象はファクトリメソッドやその他の関数になるはずです。単に関数を書き換えるだけなら何も問題はありませんが、関数から元の関数を呼びだす場合は次のような手順が必要です。

  • 元の関数を変数に保存
  • Function.prototype.apply()で自分自身をthisにセットして呼び出し

apply()を使うと可読性が下がってしまいますが、既存の関数を上書きする場合は他に書きようがないと思います。

そもそも継承を使うべきかどうか

上記で紹介した継承による機能拡張は無制限に使って良いものではありません。継承を使うと親子間が密結合になってしまい、保守性が下がることが多いです。また、パターン1bは既存コードに影響を与えかねない修正なので、より慎重に適用する必要があるでしょう。

今回のように既存のモジュールの挙動を変更する場合、次の点に注意して使うのが良いように思います(異論はあるでしょうが…)。

  • 子クラスの実装量は十分小さい範囲にとどめる
  • 機能追加は原則として新規メソッドで行う
  • 既存の関数を書き換える場合、従来の挙動を維持するよう注意する
    • 既存コードに影響を与えないようにするため
    • 特にセカンドレベル以下のモジュールの書き換えは注意

継承以外にも機能追加を実現するデザインパターンは多数あります。継承は用法・用量を守って正しく使いましょう。

まとめ

既存のCommonJSモジュールを継承して新しいモジュールを作る方法について議論してきました。私の得た結論は次のようなものです。

  • トップレベルのモジュールがクラスを返す場合はES6のクラス継承が使える
  • セカンドレベル以下のモジュールがクラスを返す場合はプロトタイプ継承で挙動を変更できる
    • アロー関数を使うと死ぬので注意
  • モジュールパターンのモジュールは単に上書きすれば良い
    • 元の関数を呼び出す場合はapply()する
  • 継承を使うべきかどうかは要検討

ES6モジュールについては私が使っていないのでわかりませんが、ほぼ同じ考え方が適用できると想像しています。


  1. そもそもクラスではないので継承というのも不適切ですが…

date-holidays という祝日ライブラリが良い意味で狂っていた

世界には色々なマニアがいるなーという話を紹介します。

先日Node.jsで使える祝日ライブラリを探していたところ、複数の国や地域の祝日に対応しているライブラリ date-holidays を見つけました。

このライブラリは本稿執筆時点で143ヶ国379地域の祝日に対応しています。この時点で頭がおかしい(ほめ言葉)のがわかると思うんですが、さらに凄いのがこれらの祝日をすべてYAMLで定義しており、このYAMLが変態的だという点です。

YAMLによる祝日の定義例

どう凄いかは実際のYAMLを見た方が早いと思うので、例を紹介します。下記は日本の祝日の定義の一部です。

      01-15:
        name:
          en: Coming of Age Day
          jp: 成人の日
        active:
          - from: 1948-07-20
            to: 1999-12-31
      substitutes 01-15 and if sunday then next monday:
        substitute: true
        name:
          en: Coming of Age Day
          jp: 成人の日
        active:
          - from: 1973-04-12
            to: 1999-12-31
      2nd monday in January:
        name:
          en: Coming of Age Day
          jp: 成人の日
        active:
          - from: 2000-01-01

1949年から1月15日が成人の日として祝日になり、1973年4月12日から振替休日の制度がはじまり、2000年から1月の第二月曜日に移動した、という記述ができています。

次にアメリカの定義を紹介します。

      01-01 and if sunday then next monday if saturday then previous friday:
        substitute: true
        _name: 01-01

アメリカも日本と同じく1/1は祝日なのですが、1/1が日曜の場合は月曜に振替休日が、1/1が土曜の場合は金曜に振替休日が発生します。こんなノリで色々なパターンの振替休日に対応できます。

このようにYAMLのキーで祝日の定義を書いていくのですが、世界各国の祝日に対応できるような記述力を持っているのが凄い点です。世界には太陰暦イスラム暦ヘブライ暦を元にした祝日があるのですが、これらにももちろん対応しています。作者の人、祝日が好きすぎでは…?

下記ディレクトリに全てのYAMLがありますので、祝日マニアの方は是非見てみてください。

日本の祝日も対応したつもり

私がこのライブラリを見つけた時点では日本の祝日が不完全でした。2019・2020年の特例に対応していなかったり山の日が大昔からあったことになっていたり実際と異なる点があったので、私の方でPull Requestを送ったところ、無事採用されました。

何十年分かはカレンダーと見比べたりしたつもりですが、まだ問題が残っているようなら教えて頂けると嬉しいです。

また、現時点で欧米のカレンダーはかなり正しそうですが、中国の祝日は不完全な気がします(連休を作るための振替出勤日に対応できていない、そもそもライブラリ側で全く想定できていない気がする)。

まだまだ世界制覇の道のりは長そうですが、このライブラリで世界中の祝日がサポートできたら楽しいですね。

ECMAScriptの浮動小数点数の丸め仕様がスゴい

ECMAScript浮動小数点数の丸め関数である Number.prototype.toFixed() について調べてみたところ、浮動小数点数をわかっている人が作った硬派な仕様だと感じたので、解説してみます。

浮動小数点数の丸めの善し悪しについて

私はプログラミング言語浮動小数点数の丸め処理に興味があり、過去に関連記事を30本以上書いています。こうした活動から得られた知見として、良い丸め関数には次のような性質があると考えています。

  • 仕様がシンプルで直感的であること
  • 仕様が抜け漏れなく文書化されていること
  • バグを作り込みにくい仕様であること

どれも良い関数の一般論のような話ですが、丸め処理に限って言えば簡単な話ではありません。そもそも浮動小数点数の性質が人の直感に反するため利用者にとっても実装者にとっても罠が多く、結果として上の条件を満たせないことが多いのです(私が面白いと感じるポイントでもあります)。

toFixed()の仕様

toFixed()ECMAScriptのNumber型のメソッドで、引数で指定された桁数までの最近接丸めを行います。無引数で呼ばれた場合は整数への丸めを行います。返り値は10進固定小数点数(要は普段よく見る小数)の 文字列 となります。

丸め方式は四捨五入です。つまり、最近接となる値が2つある場合は0から遠い方に丸めます。例えば (0.5).toFixed(0)"1" を返します。

このメソッドはJavaScript 1.5(1999年, ECMA-262 3rd Edition)で採用されており、現代のJavaScript実装であれば何であろうと動くはずです。

下記URLはES8の toFixed() の仕様ですが、記述内容は初出からほとんど変わっていません。

この仕様の素晴らしい点

この toFixed() の仕様を読んでみて、他の言語ではあまり見たことのない優れた点に気づきました。順に紹介します。

ポイント1:返り値が文字列型である

この関数は丸めの結果を文字列型で返します。これは明確な意図と知見が感じられる仕様だと思います。

仮にこの関数が浮動小数点数を返すとしましょう。(1.23456).toFixed(4) の結果は10進表記で1.2346になりますが、コンピュータ上の浮動小数点数は2進数なのでピッタリ表現できません。つまり、返り値の型が浮動小数点数だったとすると、正確な値に一番近い浮動小数点数を返すような仕様になります。言い換えると、返り値の時点で誤差を含んでしまうわけです。

実際には toFixed() の返り値は文字列ですから、10進小数を文字列の形で誤差なく表現することができます。こんな仕様が偶然生まれるわけがありません。この仕様を考えた人物は浮動小数点数の性質に詳しいだけでなく、「標準関数が仕様として誤差を含むべきではない」という強い意思を持っていたのではないでしょうか。

補足しておくと、ECMAScriptには浮動小数点数を整数に丸めるMath.round()という関数もあります。こちらは丸め桁数の指定オプションを持たず、必ず整数への丸めになるので、返り値に誤差が入ることはありません。ECMAScriptのNumber型では約9000兆までの整数をピッタリ表現できるので、正確な四捨五入が実現できます。

このように、ECMAScriptでは整数への丸め関数と10進で小数点以下n位までに丸める関数とをそれぞれ別の関数として実装しています。似た機能を持った関数を2つ作り、しかも一方は返り値の型が違うというのは言語利用者にとっては不親切かもしれません。それでもなお言語仕様として正確さの方が重要だ、とECMAScriptの仕様策定者は考えたのでしょう。これが言語利用者にとってベストの選択だったかは疑問も残りますが、浮動小数点数の都合だけで言えばベストだと思います。

ちなみにこの仕様(ECMA-262 3rd Edition)は1999年に作られています。そんな古い時期からこんな知見が文書化されていたとは驚きです。

ポイント2:最近接の値を「数学的に正確な値」で判断している

さらにこの仕様には面白い点があります。与えられた浮動小数点数を上下どちらに丸めるか計算する際に「the exact mathematical value」で比較しなさい、と書いてあります。言い換えると、丸め方向を決定する際に浮動小数点数の加減算や比較演算をしてはいけない、と言っているのです。ヤバいですね。

浮動小数点数で複雑な計算をすると計算順序や計算方法によって結果が変わってしまい、実装依存の挙動やバグの原因になりがちです。浮動小数点数演算を減らすことでバグが入りにくくなるという観点でも良い仕様だと思います。

この実装はそれほど難しくありません。丸める対象の数を10進小数で書き下して丸める桁を四捨五入すれば正確な計算をしたことになります。つまり、 (5.015).toFixed(2) の場合、5.015をIEEE754倍精度浮動小数点数として格納すると5.01499...という値になるので "5.01" となります。一方 (5.025).toFixed(2) であれば5.02500...となるので "5.03" となります。他の言語では5.01や5.03を浮動小数点数として比較するような実装もあるのですが、ECMAScripttoFixed() の実装としては誤りです。

この仕様は言語利用者から見て直感的ではないように見えるかもしれません。5.015や5.025が浮動小数点数としてピッタリ表現できないことを知らない人が「toFixed()は四捨五入のはずなのに(5.015).toFixed(2)"5.01"になった、バグだ」と誤解するかもしれませんが、このような混乱は他の仕様にしたとしても避けようがありません1。言語利用者に対しては「浮動小数点数に詳しくなってください」とスパルタ指導していくしか解決策は無いでしょう2

個人的には、「浮動小数点数の性質について理解していれば」利用者から見ても一番シンプルで説明しやすい仕様だと思います。

ポイント3:仕様の大半が擬似コードで表現されている

ECMASCriptの仕様全体の特徴とも言えるのですが、仕様の大半が擬似コードスタイルで記述されているのも面白い点です。具体的には、toFixed() の仕様は次のようになっています。

f:id:hnw:20190226101807p:plain
toFixed() の仕様(抜粋)

これは言語の実装者には非常に親切で、仕様の抜け漏れを減らして実装ごとのブレを出にくくする効果があるのではないでしょうか。他の言語の仕様ではあまり見ない気がしますが、こうした書き方も選択肢としてはアリだと思います。

一方で、言語の利用者が詳細まで読み解けるかは疑問です。仕様の副読本のようなものが必要かもしれません。

実際のブラウザでの挙動

この記事を書くにあたり、toFixed() の挙動を色々なブラウザで確認してみました。

IE8は目茶苦茶でした。IE9からIE11は幾分マシになっていますが、「the exact mathematical value」の意味がわかっておらず浮動小数点数のまま計算して妙な結果を生んでいそうな印象でした。これらのブラウザでは残念ながら toFixed() を使わない方が良さそうです。

一方モダンブラウザはほぼ完璧で、ChromeSafariFirefoxについてはバグが見つかりませんでした。Edgeだけは惜しい印象で、下記のバグが修正できれば完璧になりそうです(私も2件issueを上げていますが原因は同じに見えます)。

IEはそろそろ死んだ扱いしていいと思うので(?)、ようやく安心して toFixed() が使える時代が来そうですね。

追試したい方は色々なブラウザで下記URLを確認してみてください。

まとめ

  • ECMAScript浮動小数点数の丸め仕様は誤差が入らないことを目標にしているようで素晴らしい
    • toFixed() は10進n桁への丸めを誤差なく実現するため文字列型を返す
    • Math.round() は誤差なく数値型を返すため10進n桁への丸めを提供していない
  • 似て非なる関数が2個あって言語利用者は不便に感じるかもしれない
    • 仕様としては利用者の混乱にはあまり興味がなさそう、原理主義すぎる気もする

他の多くの言語ではround関数1つで実現している機能をECMAScriptでは(おそらく)意図的に2つに分けていること、またその関数分割には十分合理性があることを紹介しました。これはスゴいし面白いと思うんです、というお話でした。


  1. この誤解を起こさないためだけの実装 hnw/precise-round を私が提案していますが、美しくないので実際の言語で採用されることは無いと思います(私としても、あくまで議論のための実装だと思っています)

  2. そもそも浮動小数点数の性質に詳しければ内部的には100倍や1000倍した整数で処理し、表示のときだけ小数点数表示するなどしてこの手の罠を踏むことはなくなるはずです。

Karabiner-Elements で日本語キーボードを英語キーボードとして使う設定

直前の記事「Mac mini 2018を買っての感想」の通りですが、私は約3年ぶりにmacOSのマシンを買い換えまして、macOSのバージョンがEl CapitanからMojaveまで一気に3バージョン上がりました。

そこで悩んだのがキーボード配置変更ソフトです。それまでは Karabiner という有名ソフトを愛用していましたが、最近のmacOSでは別実装である Karabiner-Elements しか動きません。設定などは一からやり直しになってしまいます。

実際に Karabiner-Elements を試してみると、大半の機能についてはスムーズに移行できました。しかし、私が使っていた機能が一つだけデフォルトで用意されていません。具体的には「Use Japanese Keyboard as US Keyboard」という日本語キーボードを英語配列で使う設定です。

そこで、Karabiner-Elements版の「Use Japanese Keyboard as US Keyboard」を作ってみました。

これは Karabiner-Elements の issue #167 「change Japanese keyboard to US(JIS to ANSI) 」でsteveonjavaさんが提示したJSONをさらに修正したものです1

使い方

上記のJSONファイルを $HOME/.config/karabiner/assets/complex_modifications 以下に配置し、「Preferences…」「Complex Modifications」から「Add rule」「Use Japanese Keyboard as US Keyboard」を有効にしてください。

これを有効にすると、キーボード配置は下図のようになります。

f:id:hnw:20190104163128p:plain
適用後のキー配列(CC BY-SA 3.0 the original by Denelson83, modified by hnw)

「`~」キーが多すぎだろ、という意見もあると思いますが、Karabiner時代の設定がそうなっていたのでまあいいかという気持ちです。

Ctrl+[ をescキーとして使いたい場合は「Ctrl+@ to Escape」も有効にした上で「Use Japanese Keyboard as US Keyboard」より優先度を上げてください。

f:id:hnw:20190104172856p:plain
私の「Complex Modifications」の設定

他の解決方法

この設定を使わなくても、「システム環境設定」「キーボード」からレイアウト変更ができます。これで困らないならこちらの方が素直な解だと思います。


  1. 主な修正点は「JISキーボードのときだけレイアウト変更するようにした」「Cmd+記号など修飾キーと組み合わせた場合にも対応」の2点です。

Mac mini 2018を買っての感想

新年明けましておめでとうございます。いきなり去年の話をします。

2018年12月に自宅にMac mini 2018を買いました。シルバーグレイのちょっとオシャレな奴です。

Apple Mac mini|3.6GHz クアッドコア Intel Core i3 プロセッサ|128GB|MRTR2J/A
Apple Mac mini MRTR2J/A

同じ機種を買おうと思っている人の参考になれば、ということで感想文を書いてみます。

何をいくらで買ったか

下記のものを12月初旬にビックカメラで買いました。

  • Mac mini 2018 (第8世代 Core i3 3.6GHz、8GBメモリ、128GB SSD)96973円
  • 16GBメモリ(DDR4 SO-DIMM 2666MHz) 20898円 x2

計138769円。安くない買い物ですし、それでいてスペックは微妙に見えるかもしれません。

これはPayPayの「100億円あげちゃうキャンペーン」で衝動買いした結果です。今すぐビックカメラで買える一番おトクなものは何か?と考えた結果こうなりました。

自由に選べる状況であれば、店舗で買える高い方のモデル(Core i5 3.0GHz、8GBメモリ、256GB SSD)の方が良かった気がします。

自分でメモリ差し替えをして死にかけた

この新しいMac miniですが、公式にはメモリのアップグレードは個人でできないことになっています。とはいえネット上を探すと自分でメモリ換装している人も多いので、自力でやってみることにしました。Appleでカスタマイズすると6.6万円追加になるところ、4万円で済むならおトクですよね。

ところが、Mac miniを開けてみて気づいたんですがメモリ交換にはセキュリティートルクスドライバーが必要です。ネット上の記事をみるとみんなスイスイ開けてるんですが、私は普通のトルクスドライバーしか持っていなかったので難儀しました。買う前に調べておくべきでした。

数日後にAmazonで安いセキュリティートルクスドライバーを買って分解してメモリを交換してみました。すると画面が真っ暗で起動しません。新たなトラブル発生で焦りましたが、再度分解して改めてメモリを差し直したところ無事動作しました。メモリがきっちり差さっていなかっただけでした。

私の利用したメモリはCFD販売の「D4N2666PS-16G」というものです。DDR4 260pin 2666MHzのメモリなら大体動くんじゃないでしょうか(保証はできませんが)。

ちなみにメモリを認識しない場合、Mac miniの電源LEDが長い点滅(5秒に1回)をします。同じミスをする人はいないと思いますが、ご参考まで。

第8世代Core i3は十分な性能

メモリの交換も終わってMac miniが無事使えるようになったわけですが、今のところスペックについては不満点はありません。特にCPU性能が不安だったのですが、現時点ではそれほどCPUについてストレスを感じていません。

これまでCore i3というと遅いイメージがあったCPUだった気がしますが、今回の第8世代からCore i3のコア数が2から4になり、性能面では大きく改善されたようです。

もちろん作業内容によってはCPUのアップグレードは必須でしょう。写真や動画の編集をするような人だと上のスペックにした方がいいのかもしれません。

4Kディスプレイを常用するようになった

Mac mini 2018のHDMIポートはHDMI 2.0に対応しており、HDMIケーブル経由で4K 60Hz出力が可能です。

私はLGの24インチ 4Kディスプレイを購入しましたが、問題なく4K 60Hz表示できています。

ちなみに24インチで4K表示すると文字が小さくなってしまって厳しいので3K表示(3008 x 1692)で使っています。表示用の解像度はRetina MBPなどと同様「システム環境設定」-「ディスプレイ」から選択できます。

f:id:hnw:20190101141831p:plain
表示用解像度が5段階で選択できます

私は今までFull HDのディスプレイしか使ったことがありませんでしたが、情報量が増えるだけで世界が変わりますね。とはいえ家の広さ的には24インチや27インチで3K表示くらいが自分に合っている気がします。

スリープ復帰後にディスプレイが全部緑になる問題

しばらく使っていると、スリープから復帰したときに低確率で画面が緑になる問題に気づきました。正確にはマウスカーソルのエッジだけ黒く見え、それ以外が全て緑一色になります。この状態になるとHDMIケーブルを抜き差ししても直りません。画面表示以外は正常に動作しているようです。

ネット上に同様の挙動の報告は無いようですが、OSもしくはハードウェアの問題だという印象です。もしくは、私がメモリ換装の際に何かを破壊したのかもしれません。

ひとまずスリープしない設定にすることで問題は再現しなくなりました。

corespeechdがCPUを食う問題

しばらく使っていると、corespeechdというSiri関連のデーモンが時々1コアを使いきっていることに気づきました。

私はSiriを無効にしていたのですが、検索してみるとSiriを一度有効にしてから無効にすると直るという報告を見つけました。試してみると、実際に再現しなくなりました。

メモリは32GBあっても簡単に使い切る

このMac miniはメモリ換装の結果32GB搭載になりました。組んだ時点では絶対に使いきれないと思っていましたが、意外と頻繁にスワップアウトします。だいたい犯人はWebブラウザです。

先ほども重く感じたのでChromeを殺してみたところ、メモリが20GBほど空きました。以前のメモリ16GBのマシンではタブを100個も開くと重くて耐えられなくなっていたのが、今は200個ほどまで耐えられるようになったという印象です*1

ストレージは将来確実に不足する

私はストレージ128GBのモデルを買ってしまったわけですが、これは今どき厳しいサイズだと思います。特に音楽や電子書籍を大量に持っているような人だと絶望的でしょう。

私の場合はプログラムを書く以外に特に趣味がないので絶対無理というわけではありませんが、 それでも git clone したり docker pull したりするだけでストレージ容量はモリモリ減っていきます。長期的にストレージ不足は避けられないでしょう。

とはいえ目先は128GBでやりくりして、不足してくるころにはThunerbolt 3接続の速くて安いSSDが出ているはずなのでそれを買おう、と楽観的に考えています。

まとめ

Mac Mini 2018の安いモデルを買いました。買ってみての感想は次のようなものです。

  • コスパ良し
  • CPUはCore i5が良かった気もする、とはいえ第8世代 Core i3で十分戦える
  • メモリ32GBは案外快適なのでオススメ、最低でも16GBは欲しい
  • ストレージは最低でも256GBは欲しい、安い方のモデルはそこが厳しい
  • ディスプレイは4Kにすべき(Full HD使い回しは逆にもったいない)

余談:PayPay当選しました

最後に自慢になりますが、この買い物でPayPay満額当選しました。ラッキーですね。

PayPay10万円分当選の図
PayPay10万円分当選の図

*1:あくまで感覚値であり、本当にChromeのせいなのかは不明です

GAE/SE PHP 7.2環境は実用性が高そうだという話

筆者の周囲だけかもしれませんが、さいきんGoogle App Engine Standard Environment(以下GAE/SE)が再注目されつつあるように思います。今回筆者もgVisorベースのGAE/SE PHP 7.2環境に触ってみたので、その内容を紹介します。

GAE/SEとは

GAE/SEは元祖PaaSとも言えるような、Googleが提供するフルマネージド環境です。以前からJavaPython、Go、PHPの4言語の環境が提供されていましたが、Go以外の言語のバージョンアップは長いこと提供されておらず、Googleの本気度に疑問を持っていた人も多かったように思います(私もその一人でした)。

ところが最近になってNode.js 8、Java 8、Python 3.7、PHP 7.2と立て続けに新バージョンを提供してきており、Googleが水面下でGAE/SEに開発リソースを投入していたことが明らかになってきました。

この急ピッチの言語提供に一役買っているのが新サンドボックス実装であるgVisorです。私のざっくりの理解ではLinuxシステムコール類をユーザーランドで提供するようなもので、既に多くのOSSが修正なしで動くような対応状況になっているようです。このgVIsorはGoでフルスクラッチ実装されており、ソースコードも公開されています。

サンドボックスのGAE/SE PHP 5.5環境とPHP 7.2環境の違い

以前のGAE/SE PHP 5.5環境を使ったことがある人は、GAEは制約が強い環境だという印象を持っているかもしれません。実際、旧サンドボックス上で動作しているPHP 5.5環境には次のような制約がありました。

  • PHP本体にかなり大きなパッチが当たっている
    • SAPI名からして「Google AppEngine PHP Runtime SAPI」と普通ではない雰囲気
  • PHPから読めるディレクトリが非常に少ない
    • 自分のデプロイしたファイルくらいしか見えない
  • PHPから書き込めるディレクトリはおそらく無い
    • ファイルを作りたい場合GCSに書く必要がある
  • コマンド実行が許可されていない
    • そもそもプロセスの概念が存在する環境なのか疑問

特にファイル操作とコマンド実行の制約が大きく、既存のPHPアプリケーションがそのまま動くとは言いがたい状況でした。

一方、PHP 7.2環境は次のような状況です。

  • 普通のPHPが動いている
    • SAPIはphp-fpm
  • PHPから全ファイルが見える
  • /tmpがtmpfsになっており、PHPから書き込める
  • コマンド実行ができる
    • /usr/bin などに置いてあるバイナリが実行可能
    • Ubuntux86_64バイナリをデプロイすると実行可能
  • わりと普通のLinux環境っぽい
    • /proc/devがある(まだ少し足りていない雰囲気)

サンドボックス環境に比べれば圧倒的に普通の環境と言えそうです。PHP 5.5時代にGAEは厳しいなーと思った人がいたとしても、今回は当時とは全然違う印象になるのではないでしょうか。

PHP 7.2環境の各種コマンドの実行結果

いくつかのコマンドを試した中から面白かったものを紹介します。

ps aux

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.3  0.2  13488  5620 ?        Ssl  17:11   0:00 serve index.php
root        14  1.0  4.2 265716 88352 ?        Ss   17:11   0:00 php-fpm: master process (/tmp/serve-361831195/php-fpm.conf)
root        15  0.8  3.0 266060 63724 ?        S    17:11   0:00 php-fpm: pool app
root        16  2.4  1.1  30132 23492 ?        Sl   17:11   0:00 caddy -quiet -conf /tmp/serve-361831195/Caddyfile
root        38  0.4  0.1  12680  3240 ?        S    17:11   0:00 sh -c ps aux 2>&1
root        39  0.4  0.2  42440  5928 ?        R    17:11   0:00 ps aux

サンドボックス環境を知っていると「psコマンドがマトモに動く!」という当たり前のことに感動してしまいますね。

PID 16番のCaddyはGo言語で書かれたWebサーバ実装で、php-fpmの前段で動いているようです。

lsof -p 16

lsofコマンドでCaddyがオープンしているファイルを確認してみました。

COMMAND PID USER   FD      TYPE DEVICE     SIZE NODE NAME
caddy    16 root  cwd   unknown                      /proc/16/cwd (readlink: No such file or directory)
caddy    16 root  rtd   unknown                      /proc/16/root (readlink: No such file or directory)
caddy    16 root  txt       REG   0,14 20099720  200 /usr/local/sbin/caddy
caddy    16 root    0u      CHR    0,0             5 /dev/null
caddy    16 root    1u     FIFO   0,12             2 host
caddy    16 root    2u     FIFO   0,12             3 host
caddy    16 root    3u     sock    0,5             5 can't identify protocol
caddy    16 root    4u     0000    0,2        0   14 anon_inode
caddy    16 root    5u     sock    0,5            24 can't identify protocol
caddy    16 root    6u     sock    0,6            23 can't identify protocol

ファイルディスクリプタ3番5番6番TCPだと思うんですが、「can't identify protocol」となっています。/proc以下の未実装部分の影響でしょう。

ちなみにlsofコマンドは標準イメージ内に無かったのでUbuntu環境から持ってきたバイナリファイルをデプロイしましたが、普通に動きました。

ifconfig

eth0      Link encap:AMPR NET/ROM  HWaddr
          inet addr:192.168.1.1  Mask:255.255.255.255
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

eth2      Link encap:AMPR NET/ROM  HWaddr
          inet addr:169.254.8.1  Mask:255.255.255.255
          inet6 addr: fe80::c001/128 Scope:Global
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:AMPR NET/ROM  HWaddr
          inet addr:127.0.0.1  Mask:255.255.255.255
          inet6 addr: ::1/128 Scope:Global
          UP RUNNING  MTU:0  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

ifconfigコマンドもUbuntuから持ってきたものです。送受信パケット数がゼロなのは、現時点では未実装ということだと思います。

一部コマンドは動かない

体感値としては9割以上のコマンドがそれっぽい結果を返してくれるのですが、コマンドによっては異常終了するものもあります。

たとえばdfコマンドは未実装システムコールを呼んでいるのか「Function not implemented」というエラーが出て終了してしまいます。また、netstatを試すとサービス全体が刺さってしまい、インスタンスを再起動する羽目になりました。gVisor自体がまだ不安定なのかもしれません。

まとめ

そんなわけでGAE/SE PHP 7.2環境で色々試してみて、PHP 5.5環境よりは断然制約が少ない環境のように感じました。

これだけ普通のLinuxっぽければ言語本体の移植が早いのも不思議は無いしアプリケーションの動作もほぼ問題なしだよね、という気持ちになります*1。逆に、以前のサンドボックスがマゾすぎたとも言えそうですね…。

gVisorの開発は続いていくはずですので、その意味でも今後ますます期待できる環境だと言えそうです。

*1:たとえば既にWordPressは動いているようです

Travis CIのcron jobsを使ってGitHubに定期的にcommitする方法

みなさん、Travis CI使ってますか?Trais CIはクラウドCIサービスの1つで、GitHub上で公開しているOSSを自動テストする目的であれば定番中の定番といっていいサービスです。

ところで、さいきん私の公開しているプロジェクト「hnw/wsoui」で以下のことを実現したいと考えました。

  • ネット上のデータを加工してGoの連想配列の形で提供したい
  • 自動更新したい
    • 情報ソースは不定期に更新される
    • そこそこ最新のデータを反映していてほしい

これを実現する方法は何種類か思いつきますが、今回Travis CI のcron jobsを使って1週間に1回、更新があったときだけgit commitするような仕組みを作りましたので、これを紹介します。

Travis CI のcron jobs

Travis CIでは、gitリポジトリに新たなcommitがpushされるたびにテストスクリプトが実行されます。これにより、万一バグがあった場合でも早期に気付くことができたり、どのcommitでエンバグしたか調べる材料になったりするわけです。

また、commitごとのテスト実行とは別に、定期的にテストを行うような機能「Cron Jobs」も提供されています。これはTravis CIのコンソール画面「Settings」「Cron Jobs」から設定できます。ビルド間隔は日次、週次、月次のいずれか一つを選択できます。

本来cron jobsは依存ライブラリのバージョンアップなどで以前通っていたテストが失敗するようになっていないかを定期的にチェックする機能です。今回のようにcron jobsのときだけ別の処理を行うというのは変則的な使い方ではありますが、色々と便利な応用が考えられるのではないでしょうか。

通常のテストと自動更新との共存

さて、Travis CI上でジョブを定期実行できることはわかりました。テストスクリプト内で環境変数を見ればcron jobsかどうか判定できますから、「定期実行されたときだけ自動更新を実行する」という今回の目標は十分実現できそうです。

とはいえ、テストスクリプトにテスト以外の処理を追記してしまうと保守性が下がってしまうかもしれません。もう少し綺麗に実現する方法はないのでしょうか。

2018/6時点ではβ機能ですが、Travis CIにはBuild Stageという仕組みがあり、「cron jobsのときだけ実行」をシンプルに実現することができます。具体的には、.travis.ymlを次のように記述できます。

jobs:
  include:
    - stage: 'Test'
      script:
        - go test -v .
    - stage: 'Update Check'
      if: type = cron
      script:
        - [cron jobsのときに実行したいコマンド1] ...
        - [cron jobsのときに実行したいコマンド2] ...

このように記述すると普段はテストだけを実行し、cron jobsのときだけテスト成功後に「Update Check」処理が走ります。小さなことですが、別の処理を別の場所に記述できた方が安心ですよね。


git commitするために

さて、ここまでの知識でTravis CI上でcurlでデータを取ってきて変換スクリプトを動かすところまではできそうです。あとは生成されたファイルをgit commitするだけですが、これが意外と面倒です。

まずは対象リポジトリのデプロイキーを用意します。これはSSH鍵ペアを新規作成した上で、公開鍵をGitHubリポジトリページから「Settings」「Deploy keys」で設定します。

$ ssh-keygen -t ed25519 -f /tmp/id_ed25519
$ cat /tmp/id_ed25519.pub
ssh-ed25519 ******************************************************************** hnw@example.com

今回のようにデプロイキーで外部からpushするような場合は「Allow write access」は必須になりますので注意してください。

次に、先ほど生成した秘密鍵を暗号化してリポジトリにアップロードします。まずtravisコマンドをインストールしてそれを利用します。

$ gem install travis
(snip)
$ travis encrypt-file /tmp/id_ed25519
Detected repository as hnw/wsoui, is this correct? |yes|
encrypting /tmp/id_ed25519 for hnw/wsoui
storing result as id_ed25519.enc
storing secure env variables for decryption

Please add the following to your build script (before_install stage in your .travis.yml, for instance):

    openssl aes-256-cbc -K $encrypted_****_key -iv $encrypted_****_iv -in id_ed25519.enc -out /tmp/id_ed25519 -d

Pro Tip: You can add it automatically by running with --add.

Make sure to add id_ed25519.enc to the git repository.
Make sure not to add /tmp/id_ed25519 to the git repository.
Commit all changes to your .travis.yml.
$ git add id_ed25519.enc

暗号化した秘密鍵Travis CI上で復号して$HOME/.ssh/以下にコピーして使います。設定ファイル.travis.ymlは最終的に次のようになりました。

language: go

go:
  - "1.10"

jobs:
  include:
    - stage: 'Test'
      script:
        - go test -v .
    - stage: 'Update Check'
      if: type = cron
      script:
        - git checkout master
        - curl "https://code.wireshark.org/****" > /tmp/manuf
        - scripts/oui-convert.pl /tmp/manuf > ouidata.go
        - go test -v .
        - openssl aes-256-cbc -K $encrypted_****_key -iv $encrypted_****_iv -in id_ed25519.enc -out $HOME/.ssh/id_ed25519 -d
        - chmod 600 $HOME/.ssh/id_ed25519
        - scripts/push-if-updated.sh ouidata.go

ここで注意すべきことですが、Travis CIのテスト対象は特定commitでありGitでいうところの「detached HEAD」と呼ばれる状態になっているため、そのままでは元のリポジトリにpushできません。そのため、まずmasterブランチに切り替えるという処理を行っています。ちょっとしたTIPSですが、私は少々ハマりました。

Travis CI上で自動更新を行うメリット

上記設定により、Travis CI上で自プロジェクトの自動更新ができるようになりました。しかし、cronで実行するだけであれば、VPSを利用することもできます。わざわざ手間をかけてTravis CIで実行するメリットは何でしょうか。

個人的には、GitHub+Travis CI環境では実行に必要な全ファイルが公開されていること、また実行ログが残ることがメリットだと考えています。

今回は自動生成したソースコードを自動commitしているわけですから、おかしな内容がcommitされてしまう可能性もゼロではありません。そんな場合でも、必要ファイルとログが公開されていれば、赤の他人であっても原因をつきとめて修正してPull Requestを送ることが可能です。

こんな過疎プロジェクトで誰もPull Request送らないでしょ、というツッコミもあるとは思いますが、可能性って大事だと思うんですよね。

まとめ

  • Travis CIのcron jobsを使うと定期実行ジョブをソースコードつき・ログつきで公開できる
    • インターネット上で更新され続けている何かを変換してgit commitするような用途には非常に良いのでは?たとえば日本の祝日カレンダーライブラリなど。