ES6 Javascript の Proxyの使いどころ。もっと早く知っておけばよかった!
こんにちは!
最近、しずかなかずしブログでプログラミングネタが減っていたので、プログラミング好きの読者が残念がっているかも、と思えるほど読者がたくさんいればいいのに、と思う今日この頃。しずかなかずしです。
最近、プログラミングしてますか?
お待たせしました。本日は、Javascriptです。
EcmaScript6(ES6)で導入された、Proxyという仕組みを突然ですが取り上げます。
Proxyとは
Proxyは、2つの引数 targetとhandlerを指定して生成すると、そのオブジェクトが、targetの処理に割り込んで別の処理をさせることができる仕組みを提供するものです。割り込んだ処理はhandlerに記載します。
割り込むハンドラーはトラップと呼ばれたりしています。
例えば、targetとなるオブジェクトを dog と想定しましょう。ペットですね。
ペットには名前があるので、nameというプロパティがあるでしょう。JSでは、dog.nameでアクセスするのはご存知の通り。
Proxyでは、handlerにgetという関数を設定して、dog.nameに参照したことを「トラップ」して別の処理をさせることができます。
別の処理とはなんでしょう?
「name」というプロパティにアクセスしたときに、別の値にしてしまったり、参照された時点でログを出力したり、プロパティに新しい値を書き込むときに、正しい値かチェックする、などなど。handlerに処理を書けば、何でも実現できます。
なんという素晴らしい仕組みなのでしょう。
ちょっとやってみます。
以下のコードでは、dogというオブジェクトは、nameとageを持っています。最初は、「トム」という1歳の犬です(2行目)。5行目で、「トムは、1歳の犬です」と表示されます。
え?トムって犬なの?
というのはとりあえず、置いておきます。
9行目でハンドラーを定義していますが、getという関数を用意します。ここで、propに’name’が入ってきたときに、いきなり’ジェリー’を返しています。このようなハンドラーをProxyオブジェクトに渡して、dogを置き換えることができます。(’name’以外のプロパティに関しては、そのままにしたいので、target[prop]を返します。14行目)
すると、どうでしょう?dogはジェリーになります。
え?ジェリーって犬なの?
実行するとこんな感じになります。
Applyを使う
ここまでは、一般的なProxyの使い方のお話でした。
JavascriptのProxyの使い方を検索すると、なぜか、プロパティを置き換える話がよく出てきます。
しかし、私としては、そのような使い方よりも、オブジェクトの関数呼び出しをトラップしたいと思うケースに何度が遭遇し、そちらが気になりました。
先の例では、handlerにget()を定義してプロパティの読み出しを「トラップ」しました。一方、handlerに定義できる関数はもっと色々あり、MozillaのMDNのProxy/Handlerのページを参照すると、一覧を入手できます。
その中に、handler.apply()というものがあり、関数呼び出しに対するトラップであると書かれています。
例えば、以下のように使います。handlerには、apply()を定義しています(13から14行目)。targetは元の関数が入ります。なのでapply()関数のなかで、新しい引数(59)でtarget、つまり元のshowtimeを呼び出す実装をしてみました。
実行結果の最初の3行は、引数で渡した、1,2,3が表示されますが、後半の3行は「トラップ」した関数が実行されており、パラメータが想定通り全て59に置き換わってしまいました。
Proxyの使いどころ
関数呼び出しをトラップする、と言っても上の例だと単純すぎて何に使うのかわからないですね。
そこで、こんな例はいかがでしょうか?
とあるオブジェクトのメンバー関数呼び出すをトラップするケースです。
NodejsのStreamオブジェクトを使って、ちょっと実用的な話にしてみます。
Nodejsにはデータの読み書きを非同期のストリームとして扱うStreamクラスというものが定義されています。Readable streamは、ファイルやネットワークからバイトストリームを読み込む際に、framework(OS)が読み取れる準備ができたところで、順々に読み取り処理することができる、という代物です。予めデータのサイズがわかっていなくても、小さなデータに分割されて読み出し、都度処理を続ける、という場合に便利です。
Streamクラスを使うと、何百メガバイトもあるような巨大な画像ファイルを扱っても、プログラムをブロックせず、また、画像ファイルのサイズ分のメモリーを一度に確保することなく逐次的に処理できるため、効率がよいプログラムが書けます。
Streamオブジェクトを誰かに渡すと、そのオブジェクトからデータを順番に読み込み、コピーするなり、圧縮するなり、いかようにも調理できるため、ネットワークのプログラミングではちょいちょい登場するわけです。
また、NodejsのFSというライブラリは、HDD/SSDといったファイルシステムからファイルを読み取るための、Readable streamオブジェクトを生成する機能が実装されています。
以下のサンプルでは、このReadable streamオブジェクトのメンバー関数read()を置き換えて、読み取り処理が実際に行われる際に、別の処理をするようなケースを考えてみます。
置き換える前のコード
以下の、サンプルコードで見てみましょう。
変数readableは、FSライブラリのcreateReadStream()関数でインスタンス化します(5行目)。
これをdoSomethingWithTheStreamという関数に渡して非同期で処理を実行します。戻ってくるのは、Proxyと同じくES6で定義されたPromiseオブジェクトです。こういう非同期処理はPromise.then()で待受けます(8行目)。以下のプログラムは、非同期のdoSoemethingWithTheStream()の実行が終わったら’All done!’を表示するシンプルなものです。
一方、doSomethingWithTheStream()関数側の実装(別ファイル)は以下になります。
第1引数readableにイベントリスナーを登録(6行目)しています。このイベントはシステム(OS)で読み込みできるデータが揃ったタイミングで発火しますので、ここでreadable.read()を使って実際に読み取ります(9行目)。データが既に揃っているので、IOブロッキングすることなく、read()はすぐに返ってきます。これが非同期読み込みのいいところです。
また、ストリームの読み込みが完了した後に発火するイベントのリスナーも登録(15行目)しています。ここで、Promiseのresolve()を呼び出すと、呼び出し元のthen()に処理が移ります。これはPromiseの基本的な仕組みですね。
Promiseに関しては以前、このブログ「しずかなかずし」で別の解説記事がありますので、そちらも合せてご覧ください。
Applyでメンバー関数をトラップ
それでは、Readable streamオブジェクトのread()をトラップしてみます。
上で登場した、fs_sample_main.jsをちょっと変更しました。doSomethingWithTheStream()関数を呼び出す前に、Readable streamオブジェクトをProxyで置き換えています(10行目)。
Proxyの第2引数は、handlerでしたね。今回は、handler変数を定義することなく、11行目から17行目でProxyのパラメータとして直接handler.apply()関数の実装を書きました。
この例では、オブジェクト変数readableが処理するデータをread()関数でトラップすることにより、転送バイト数をtotalBytes変数でカウントしています(7行目と15行目)。
元のStreamのread()関数の実行は、13行目のtarget.apply()で行います。
クラス継承(extends)ではダメなのか?
オブジェクトの一部の関数の処理を置き換えるなら、「クラス継承」をして置き換えたい関数を「オーバーライド」すれば事足りるのではないでしょうか?
JavascriptではバージョンES6で、他のオブジェクト指向のプログラミング言語で一般的に使われていたclassキーワードがやっと登場しました。extendsを使うと「クラス継承」も記述可能です。それ以前のJavascriptでは、classやextendsがなかったので、functionやprototypeというキーワードを使った他のやり方でクラスやクラス継承を実装せざるを得なかったのです。
ES6をつかうのであれば、extendsを使えば、一部のメンバー関数の処理の置き換えも可能になるのでは?!
甘い!上で取り上げてきた例は、それができないのです。なぜでしょう?
先程のサンプルのfs_sample_main.jsを再掲します。
Readable streamクラスを継承した子どもクラスを作ろうにも、Streamクラスのインスタンスを作成しているのは、FSライブラリの中です(5行目)。独自に継承したクラスをインスタンス化するためには、FSライブラリに手を入れる必要が出てきてしまいます。
従って、このケースの場合は、インスタンス化されたReadable streamオブジェクトを動的に書き換えざるを得ず、Proxyクラスがその動作に一役買っているという訳です。
このように、一旦何者かがインスタンス化してしまっているオブジェクトの関数呼び出しをトラップする際にProxyは便利に使えます。
例えば、以下の例はどうでしょう?
Nodejsでプログラムを書いていて、コンソールにメッセージを表示したいことはよくあります。
そこで登場するのがconsole.log()という関数。Hello,world!を表示させる場合にも使うので、初心者にとてもおなじみの関数でしょう。
ここで出てくるconsoleというオブジェクトは一体誰がインスタンス化したのでしょう??
実は、システムが勝手に作っています。
このように既に作られてしまったインスタンスのメンバー関数(ここではconsole.log())の挙動を変えたい。そんな場合にもProxyが使えそうです。静的に構造を定義してしまう、クラス継承ではできない芸当ですね。
以下の例では、console.log()をトラップして、出力する文字列の先頭に'(XXXXXX)’のように現在時間を表示させています(7行目)。
もっとも、consoleの仕組み自体は、拡張性がもともとありますので、このようなことはしなくても良いかも知れません(Console | Node.js v14.11.0 Documentation)。
以上、Javascript Proxyオブジェクトの使いどろこ、でした。