hnwの日記

ユニットバスの天井裏に備え付けのL2スイッチを発見した話

(2021/06/23追記: この記事を書いてしばらくして引っ越しました。原状復帰済みです。念のため。)

私は同じ賃貸住宅に10年ほど済んでいるのですが、ごく最近になって自宅内に備え付けのL2スイッチが存在することに気づきました。ソイツはなんとユニットバスの天井裏にいたのです。

このスイッチをGbEスイッチにリプレースしたところ、自宅のコンピューティング環境を改善することができました。本稿ではその顛末を紹介します。皆様のお風呂場探検の参考になれば幸いです。

謎の情報コンセント

読者の皆さんは情報コンセントというものをご存じでしょうか。下の写真のようにイーサネットケーブルを差すコンセントのことを言うそうです。

f:id:hnw:20200920025819p:plain:w300
我が家の情報コンセント

これがない家もあると思いますが、私が今住んでいるマンションには情報コンセントが部屋ごとについています。

この説明は入居時に一切受けていないのですが、試しにイーサネットケーブルをつないでみると部屋間が100Mbpsでつながることがわかりました。

いまどき100Mbpsというのもひどい話ですが、賃貸の設備にケチを付けても仕方がありません。きっと大家がケチって4芯のクロスケーブルが壁に埋まってるんだろう、と思っていました。

しかし、我が家の情報コンセントは全部で3口あります。冷静に考えれば3口がクロスケーブルで接続されるはずはないのですが、当時の私は部屋の間にイーサネットケーブルを通す手間が省けた喜びで思考停止していました。

見知らぬL2スイッチ

そんなこんなで謎の情報コンセントを9年ほど使い続けていました。情報コンセントの両側にはGbEスイッチを置いていたので両者の接続が100Mbpsなのは不満でしたが、壁に埋まっている設備の問題であって自分で何とかできるとは夢にも思っていませんでした。

そんなある日、ネットサーフしていると「お風呂場の天井裏をサーバールームにしてみよう」というブログ記事を見つけました。集合住宅ではユニットバス上部の空きスペースを各種配線スペースとして利用することがあり、イーサネットケーブルが出ていることも多いようです。

tomeapp.jp

これは我が家も同じ状況なのでは?と考えてユニットバスの天井を開けてみると、案の定バッファローの100Mbpsスイッチがピカピカ光っていました。スイッチには4本のCAT5eケーブルが接続されており、3本は各部屋の情報コンセントにつながっていました。残りの一本はマンションのMDF室につながっているようですが現状では使っていないようです。

情報コンセント間が100Mbpsで接続されている謎が解けた瞬間でした。

f:id:hnw:20200920032213p:plain:w300
お風呂場のココ、開けられるんですよ

f:id:hnw:20200920030324j:plain
ユニットバス天井裏で発見したバッファローのスイッチ

湿度を測定する

ユニットバスの天井裏は一定の広さがありますから、先の記事で紹介されているようにサーバルームとして使うのは面白いアイデアです。とはいえ、L2スイッチが湿気でダメになったりしないのでしょうか。

多くの人は問題ないと結論づけているようですが、自分でも確認してみることにしました。Raspberry Pi Zeroを用意し、温湿度センサーを付けた上でユニットバス天井裏に設置してMackerelでグラフ化した結果が以下になります。

f:id:hnw:20200920032748p:plain
入浴前後の湿度の変化 (3月)

f:id:hnw:20200920034921p:plain
入浴前後の湿度の変化 (9月)

センサーは2つ用意したのですが、オレンジ色のグラフの方が実際の値に近いようです1

この結果によれば、ユニットバス天井裏は入浴時に湿度が上がるものの、冬場で+5%、夏場で+2%程度の上昇幅でした。入浴時はユニット内の温度上昇の影響で天井裏も温められるため、飽和水蒸気量が押し上げられて結果的に湿度の上昇が抑えられているようです。また、夏場の方が高温なので飽和水蒸気量が元々大きく、浴室利用の影響が相対的に小さくなるということも言えそうです。

結論としては入浴によるユニットバス天井裏への影響は限定的で、どうやら結露するようなことはなさそうです。

スイッチの置き換え

湿度の心配もなさそうなので、ユニットバス天井裏を活用していきましょう。

部屋間の接続速度が遅い原因はバッファローの100Mbpsスイッチでした。そこで、このスイッチをGbEスイッチに置き換えたところ、部屋間の接続が1Gbpsになりました。賃貸住宅だから仕方ないと思っていた点が改善できたのは嬉しいですね。

リプレースしたスイッチはVLANに対応しているので、機能面でもパワーアップしたことになります。

f:id:hnw:20200920035418j:plain
ちょっと良いスイッチでリプレースしました

マシンの設置と所感

参考にしたブログ記事ではユニットバス天井裏にサーバ類を置いています。なかなか面白いアイデアですので私も同じことをしてみました。

ユニットバス天井裏は非常にほこりっぽい空間なので、まずは念入りに掃除をしました。また、各種機械類を直接ユニットバスの天井裏に置くのは気が引けたので、100均で買ってきたミニすのこを敷くことにしました。

この空間にリモートデスクトップ専用機になっていたThinkpadを置いてみたところ、不自由なく使えることがわかりました。このマシンは稼働時にファンがうるさいのが気になっていたのですが、音が聞こえない場所に置けるようになったのは良い点と言えそうです。

一方で、ここに大きい機器・重い機器を置くのはオススメしません。天井裏へのアクセスは不便なので、あまりに重いものを置くと設置の際に腰をやったりするかもしれませんし2、大きいものだと天井裏へ通すときに事故るかもしれません。湿度その他の理由で壊れる可能性もゼロではありませんから、高額な機器や大電力を消費する機器も置かない方が良いでしょう。

また、事故があったときに気づきにくい場所なので、温度・湿度のモニタリングは必須と言えるでしょう。こうした監視にRaspberry Pi Zeroはかなり良い選択肢です。私の場合うまく使えそうなHATとブレイクアウトボードがあったので雑に天井裏に投げ込むことができました。また、Mackerelにはしきい値を超えるとSlackに警告を飛ばす設定があるので、これも設定してみました。

f:id:hnw:20200920110012j:plain:w300
設置したRaspberry Pi Zero

まとめ

筆者の自宅のユニットバス天井裏に備え付けのL2スイッチが動いていたこと、これを置き換えたところ家のネットワークが増速したという事例を紹介しました。他のお宅でも同じような状況かもしれませんので、一度ユニットバス天井裏を探検してみてはいかがでしょうか。


  1. 青いグラフに対応するセンサーはRaspberry Pi ZeroのCPUとの距離が近く、気温の測定値が周辺気温より高めに、湿度が低めに出ていました

  2. 天井の強度にも不安があります

PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話

PHP 7.0のリリースから約5年が経過し、そろそろPHP 8.0のリリースも見えてきました。人によっては使い始めて5年目になるはずのPHP 7.xですが、いまだに新しい発見があったりして面白いですね。

本稿ではPHP 7.0から入った定数配列に関する性能改善について紹介します。

PHP 5時代は配列の組み立てコストが大きかった

プログラミング上のテクニックとして、辞書データを連想配列としてプログラム中に記述し、これを必要に応じて使うというものがあります。たとえば次のコード例を見てみましょう。このような連想配列を持っておけば、プログラム中で国名コードをを扱う際に実在するかをチェックしたり、国名の日本語表記に変換したりといった処理ができるわけです。

<?php
$country_name = [
    'jp' => '日本',
    'us' => 'アメリカ合衆国',
    'ru' => 'ロシア連邦',
    /* 以下略 */
];

ところで、こうした辞書的なデータはPHPでどのように処理されるのでしょうか。PHPでは上記のコードが配列に1要素ずつ追加するopcodeにコンパイルされ、実行時にopcode列を実行することで配列が組み立てられます。つまり、プログラム中に1万要素の連想配列が登場すると1万個のopcode列にコンパイルされて実行されるわけです。この実行コストが高いため、PHPで巨大配列(数万要素オーバー)を作るのは重いといわれてきました。

どれくらい重くなるのか、下記プログラムで実際に試してみましょう1

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

39万要素の二次元配列を返す foo() を呼び出す処理です。これを私の手元の環境(Mac Mini + nginx + PHP 5.6.40)で実行して実行時間を測定しました2

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
267 ms 436 ms 87 ms

「OPcacheなし」と「OPcacheあり2回目以降」を比較すると、処理時間267msのうちの67%がコンパイル処理だということになります。コンパイル結果をキャッシュすることで速度が3倍になると考えればOPcacheの効果は大きいですね。一方で、連想配列の組み立てだけで87msかかっているわけで、OPcacheがあってもまだ遅いという見方もできます。実際にはここまで大きい連想配列を扱うことはないと思いますが、数万要素くらいなら実戦投入している現場があるはずですから、改善できるなら改善したいですね。

PHP 7でも連想配列の構築はそれなりに重い

PHP 7からは連想配列のデータ構造が改善され、高速・省スペースになったことはよく知られています。PHP 7.xでも連想配列の組み立て処理は重いままなのか確認してみましょう。

さきほどのプログラムをPHP 7.4.9で実行してみました。結果は以下の通りです。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
469 ms 9106 ms 25 ms

「OPcacheあり 2回目以降」同士で比較すると実行時間の改善はすごいですね。PHP 7の配列はさすがに速いということでしょうが、それでも25msは無視できない重さです。PHP 7をもってしても巨大配列の構築は高コストというわけです。

ところで、OPcacheの初回の処理がとんでもない重さになっているのも気になりますね。OPcacheの初回処理では最適化処理が走るので一般的にOPcacheなしのときより時間がかかるのですが、それにしても不安になる遅さです3。実戦でここまで遅くなることは少ないでしょうが、大きめの連想配列を扱う場合はPHP 7.4で採用されたコードの事前ロード機能を使ったり、別途ウォームアップ処理を実行した方が無難かもしれません。

定数配列ならコンパイル時に構築されキャッシュされるので高速(PHP 7.0以降)

ようやく本題です。PHP 7.0から定数配列4の扱いが変わっており、他の配列より性能面・メモリ消費面で有利になっています。

先ほどから実験に使っているプログラムでは連想配列の一部に変数を含んでいるため、コンパイル時に配列全体を確定することはできません。もし配列のキーと値の全てが定数であれば、コンパイル時に配列全体を確定できます。このような配列を本稿では定数配列と呼ぶことにします。

定数配列ではコンパイル時に配列全体が構築され、プログラム中でこれを利用するようになります。また、OPcacheが有効であれば初回コンパイル時に作られた配列がキャッシュされ、以降のリクエストで使い回されます。

定数配列の挙動を確認するため、先ほどのプログラムと要素数は同じまま値を全て定数にして実験してみましょう。

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

上記プログラムを PHP 5.6.40 と PHP 7.4.9 で試した結果が下記になります。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
PHP 5.6.40 313 ms 450 ms 85 ms
PHP 7.4.9 331 ms 458 ms 1 ms

PHP 5.6.40では変更前のプログラムと大差ない結果になりました。PHP 5では定数配列だからといって有利というわけではないようです。

一方PHP 7.4.9では全てのケースで改善が見られました。特にOPcacheあり2回目以降の1msというのは衝撃的な結果です。先ほどの実験によれば真面目に組み立てたら25msかかるサイズの配列を1msで返しているわけですから、定数配列の使い回し効果は絶大といえるでしょう。

もちろん初回アクセス時はそれなりに時間がかかるわけですが、これが気になる場合はコードの事前ロード機能を使うなどすれば実質使い放題というわけです。

まとめると、PHP 7ならどんなに巨大な定数配列でも実質タダで使えるわけですよ。やりましたね、PHPユーザーの皆さん!

まとめ

  • PHPの配列はopcode列としてコンパイルされるため、OPcacheありでも巨大配列の生成コストは高い
    • PHP 5ではこの問題が顕著だった
  • PHP 7から定数だけで構成された配列はコンパイル時に生成されるようになった(定数配列)
  • 定数配列はOPcacheのキャッシュ対象になっているため、キャッシュヒットすれば生成コストなしで使える

本稿の内容はイマイチ知られていない気がしますが、これを活用できるプロジェクトは多そうです。性能に影響を与える規模の配列・連想配列(数万要素程度?)を使っている場合、PHP 7の定数配列を利用できないか検討してみてはいかがでしょうか。


  1. 追試したい方のためにプログラム全体をgistにアップロードしました(巨大ソースコードなので、ブラクラ的な意味で閲覧注意です)

  2. ここで示した実行時間はnginxのログ $upstream_response_time で取得し、何回分か平均を取ったものです。

  3. OPcacheのイケてない実装を突いてしまった可能性がありそうです…

  4. PHPの内部ではimmutable arrayと呼ばれているもの。あまり直感的な名前ではないと感じたので、本稿ではこのように呼びます。

Gmailの新着メールをSlackに転送するGASを書いた

みなさん、Slack使ってますか?会社で使ってる人も多いでしょうし、サークルやコミュニティで使っているような人も多いと思います。ここ数年で一気に広がった感じがありますよね。

そうやってSlackに慣れてくるとSlackにさまざまな情報を集約したくなってきます。プログラマであればGitHub連携にはメリットを感じていると思いますし、エゴサ好きな人ならIFTTTでTwitter検索の結果をSlackに通知するなんていう使い方もあります1

そんなある日、私は特定のメールを無料プランのSlackに転送したくなりました。この機能はSlackの公式App「Email」で実現できるのですが、残念ながら有料プランでしか使えません。

さらに言うと、大昔は同じ機能をIFTTTで実現できたんですが、2019年にIFTTTのGmailトリガーが封じられてしまってIFTTTでは不可能になってしまいました2

そんな状況でしたので、GmailのメールをSlackに転送するGoogle Apps Script (GAS)を作ってみました。本稿ではこの概要と導入方法を紹介します。

概要

今回、GmailのメールをSlackに転送する仕組みをGASで実現しました。GitHubに公開してあります。

GASというのはGoogleが提供するスクリプト実行環境で、Googleアカウントを持っていれば誰でも使えます。Googleサービス(ドライブ文書やGmail・カレンダーなど)との親和性が高いので、これらを自動化する環境としては実質一択と言っても良いでしょう。完全無料なのも嬉しいところです。

また、GmailからSlackへの転送にはIncoming Webhookを使っています。これを使うにはSlackのアプリ追加権限が必要です。Slackワークスペースの管理ポリシー次第では使えないこともありますので、ご注意ください。

ちなみに、転送されたメールはSlack上で次のような見た目になります。

f:id:hnw:20200613124149p:plain
メールをSlackに転送したときの見た目

メールのSubjectと本文の先頭4行だけが表示されており、「Show more」を押すと全文が見えるようになっています。

導入の流れ

導入までのステップはざっくり言うと下記のようになります。

  1. Gmailアカウントを新規作成する
  2. 1のアカウントでGASをデプロイする
  3. Slackにメール転送役のbotユーザーを追加する
  4. 3のユーザーに紐付くIncoming WebhookのURLを発行してGASのプロパティに設定する
  5. 1のアカウントにメールを転送する
  6. GASの時間主導型トリガーで起動間隔を設定する(1分おきから1時間おきくらいが適当でしょう)

面倒に見えると思いますが、実際面倒ですね…。Gmailアカウントは既存のものを使うこともできるのですが、導入手順が更にややこしくなるのでオススメしません。

具体的な導入手順やカスタマイズ方法などはREADMEをご確認ください。

使ってみての感想

この仕組みを運用して6ヶ月ほど経ちましたが、自分の用途には問題なく使えています。

自作して良かった点は細かいカスタマイズをしやすいところですね。私の場合はメールの前に任意のテキストを追加したい(これを別のbot向けのコマンドとして利用したい)というニーズがあったり、他にも何点かやりたいことがあったので自作して良かったと思います。

とはいえ有料プランならSlack公式のEmailアプリを使わない手はないでしょう。EmailアプリはHTMLメールを同イメージでSlack上に再現してくれるのが素晴らしい点で、自作で真似しようと思うと結構面倒なんですよね…。


  1. 病むタイプの人にはオススメしません

  2. Office 365 Mailトリガーなら今でも使えます

パスワードをプレーンテキストで保存してはいけないという話とその解決策

プログラムから使うパスワードをどう保存するか

外部サービスにアクセスするプログラムを書く場合、そのサービスの認証方式は何か、認証鍵をどう保存するか、というのはシステム全体のセキュリティを考える上で重要な話題です。

昨今のWebサービスであれば、クライアントごとに権限を絞ったアクセストークンを発行し、万一トークンが漏れた場合には漏れたトークンのみを無効化するような仕組みが提供されていたりします。こうした仕組みがあれば鍵の取り回しについて悩む必要はないでしょう。

一方、パスワード認証のサービスをプログラムから利用する場合、そのパスワードをどう管理すればよいのでしょうか。例えば銀行のネットバンキングシステムに自動ログインするプログラムを書く場合に、プログラムが利用するID・パスワードを平文で保存することに問題はないのでしょうか。

この問題についての議論を私はほとんど見たことがありませんが、関係しそうな文章をOWASPで見つけました。

この文章によれば、「パスワードを平文のプレーンテキストで保存してはいけない、BASE64のような可逆なエンコーディングもダメだ」とのことです。明示的には書いていないのですが、可能ならパスワードは保存しない方が良い、保存するなら暗号化すべき、ということのようです。

パスワード平文保存のリスクを考える

それにしても、パスワードが暗号化されていると何がどれほど安全になるのでしょうか。

仮に、すべてのローカルファイルを閲覧可能な脆弱性があったとすると、パスワードが暗号化されていたとしても大した意味はありません。というのも、プログラムから暗号文を復号できるということは共通鍵もシステムのどこかに保存されているはずですから、攻撃者も共通鍵を使って平文を入手できてしまうからです。

となると、OWASPの指摘はローカルファイルのうち一部のみが漏洩したような場合への対策だと考えられます。この種の漏洩の典型例はショルダーハック(盗み見)ではないでしょうか。

PASSWORD = foobar # 怖い
PASSWORD = Zm9vYmFyCg== # BASE64。依然怖い

上記のように平文パスワードをファイルに保存していて、このファイルを編集中に誰かが後ろを通りかかったり、運悪く写真を撮られてしまったような場合を考えると、システムに脆弱性がなくてもパスワードが漏洩する可能性は十分にあります。

PASSWORD = sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A== # 少し安全

このようにパスワードを暗号化しておけば、暗号文と共通鍵の両方を盗み見られない限りパスワードは漏洩しません。このように考えるとOWASPの指摘は十分意味があるように思います。

なんちゃって暗号化ライブラリを作った

私は銀行に自動ログインするプログラムを書いており、当初はパスワードを平文で保存していたのですが、上記のように考えてパスワード暗号化ライブラリを作成しました。

これはNode.js用ライブラリで、ランダム生成したパスワードを元にCBCモードつきの共通鍵暗号(デフォルトはAES)で暗号化・復号を行います。npmに公開してありますのでコマンド一発で試せます。

$ npm install @hnw/easyaes

使い方としては、まず最初にパスワードを生成します(手動で作ってもいいです)。

$ $(npm bin)/easyaes --keygen > $HOME/.easyaes

次に、暗号化したい文字列を標準入力経由で入力して暗号文を得ます。

$ $(npm bin)/easyaes --encrypt
foobar #標準入力から入力した平文。実際にはエコーバックされません
sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A== #出力された暗号文

この暗号文は次のようにJavaScriptから復号できます。

const EasyAes=require("@hnw/easyaes");
cipher = new EasyAes();
console.log(cipher.decrypt('sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A==')); // foobar

このように暗号化した値を設定ファイルで使ったりコマンドラインオプションから与えたりして、これをアプリケーション内で復号すればショルダーハックに対してセキュアになるわけです。

これはセキュリティの観点では保険的対策でしかないのですが、精神的な安心度はかなり高まるように感じています。というのも、一部銀行ではいまだにパスワード長が8文字までに制限されているので、平文で保存するとショルダーハックのリスクが非常に高いのです。

もっとマトモなソリューションもある

私の場合はプライベート用途かつ管理者が私だけの環境だったので上記の実装で必要十分だと考えていますが、もっとシリアスな状況ではもっと真面目に鍵を管理すべきです。

たとえばお仕事でセキュアに鍵を管理する目的であればAWS KMSとかHashiCorp Vaultなどを使うのが良いでしょう。これらのソリューションはアクセスコントロールを細かく制御できたりログが取れたりするのが良い点だと思います。

また、マシン起動直後だけはパスワード入力を要求して、以降は入力されたパスワードをメモリ上に暗号化して保存しておくなどの選択肢もあるでしょう。

まとめ

パスワードを平文保存するよりはマシな「なんちゃって暗号化」ライブラリを作りました。ショルダーハック対策くらいにしかなりませんが、短いパスワードや暗証番号をテキストファイルに保存するような場合は特に有用だと感じています。

念のため補足してきますと、銀行パスワードのような重要な情報を扱う場合に一番大切なのはマシン自体のセキュリティレベルを高くすることです。例えば、私ならグローバルIPアドレスを持っているマシンや共用マシンではパスワードのローカル保存は避けます1。本稿で紹介したライブラリは十分安全な環境で更に安心を得るためのものであり、その前提がないと無意味だという点にご注意ください。


  1. 個人的には、私自身が管理している宅内NAT環境であれば十分セキュアだと考えていますが、何をもってセキュアと考えるかの線引きは難しいところです。ITリテラシーが高いつもりの人でもマルウェアをインストールしてしまう可能性はあるわけで、どんな環境であろうと銀行パスワードレベルの重要情報はストレージに保存しない、という考え方もあると思います。

既存の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倍した整数で処理し、表示のときだけ小数点数表示するなどしてこの手の罠を踏むことはなくなるはずです。