Posted at

Hackで作る簡単サービスロケーター

前回はHackでdotenvライブラリの実装例について紹介しました。

今回はこれまで紹介してきたHackのいくつかの機能を使って、

DIとしても利用でできるサービスロケーターを作ってみましょう。

ものとしてはPHPの pimple/pimple 並みのライトなものです。


HackとPSR-11

PSR-11は、

いわゆるDI・サービスロケーターなどインスタンス生成を担当するコンテナライブラリの標準インターフェースとして、

PHPのライブラリに採用されています。

しかしながら、HackにおいてPHPのインターフェースは残念がら実装したとしても

数ヶ月のうちに利用できなくなる(HHVM3.30まで)、

PHPの型はHackの型として認識されないため、strictとして実装できない、

と非常に使い勝手が悪く、

Hackで実装するメリットは今のところありません。

もしPSR-11互換としたい場合は、PSR-11インターフェース向けにhhiファイルを作成する必要があります。

hhiはytake/psr-container-hhi が利用できます。

PSR-11に準拠したい場合は、

以降に取り上げる実装コードをstrictではなくpartialで実装してください。


ライブラリ利用イメージ

シンプルなものであれば、以下のもので十分利用できますので、

callableはHackのLambdaを使って、インスタンス生成を登録するようにします。

$container = new Container();

$container->set(\stdClass::class, ($container) ==> new \stdClass());
// stdClass
$container->get(\stdClass::class);

ここでは名前空間を下記のものとして実装していきます。

namespace Acme\HackDi;


開発の準備

composer.jsonは下記の通りです。

{

"name": "acme/hackdi",
"type": "library",
"require": {
"hhvm": "^3.30.0",
"hhvm/hhvm-autoload": "^1.6",
"hhvm/hsl": "^3.30.0"
},
"require-dev": {
"hhvm/hacktest": "^1.3",
"facebook/fbexpect": "^2.3"
}
}

hh-autoload.jsonを下記のものとします。

{

"roots": [
"src/"
],
"devRoots": [
"tests/"
]
}

.hhconfigは以下のものになります。


.hhconfig

assume_php = false

ignored_paths = [ "vendor/.+/tests/.+" ]
safe_array = true
safe_vector_array = true

準備ができたらcomposerコマンドで依存ライブラリをインストールします。

$ hhvm $(which composer) install


インスタンス登録

下記の処理の部分です。

$container->set(\stdClass::class, ($container) ==> new \stdClass());

インスタンス生成といえば、

シングルトンかそうでないかを選択できるようにする必要があります。

Hackならではということで、これらにはEnumsを使うようにします。


Acme\HackDi\Scope

namespace Acme\HackDi;

enum Scope : int {
PROTOTYPE = 0;
SINGLETON = 1;
}


次にコンテナ内にインスタンス生成方法と名前を紐づける為の仕組みを用意します。

Mapなどのコレクションを利用しても構いませんが、

ここではdictを使って実装します。

HackではcallableはPHPと同様の型宣言は利用できません。

利用イメージのものをHackで型宣言する場合は、

次のものになります。

(function(\Acme\HackDi\Container): mixed)

毎回記述するのはちょっと面倒ですね。

この型に別名をつけて以下のようにしましょう。

type TCallable = (function(\Acme\HackDi\Container): mixed);

上記のように記述するとこのcallableな型を TCallable とすることができます。

インスタンス生成方法と名前は下記のdictで表現できそうです。

private dict<string, (Scope, TCallable)> $map = dict[];

名前をキーに、インスタンス生成の為のスコープとcallableの処理をtupleとしてペアにしました。

これ以外のも様々なものが考えられますが、

シンプルな利用方法であれば、このくらいで十分活用できるはずです。

以上を踏まえると下記の実装になります。

<?hh // strict

namespace Acme\HackDi;

type TCallable = (function(\Acme\HackDi\Container): mixed);

enum Scope : int {
PROTOTYPE = 0;
SINGLETON = 1;
}

class Container {

private dict<string, (Scope, TCallable)> $map = dict[];

public function set(
string $id,
TCallable $callback,
Scope $scope = Scope::PROTOTYPE,
): void {
$this->map[$id] = tuple($scope, $callback);
}
}

setで同じ名前が登録された場合は、単純に上書きになっています。

制約を与えたい場合は、登録済みかどうかの処理を加えてもいいでしょう。


コンテナに登録済みかどうか

PSR-11実装ではありませんが、PSR-11ライクにhasメソッドを利用してみましょう。

dictに対して、値が登録済みかどうかはarray_key_existsを利用します。

(issetはHackでは非推奨)

array_key_existsをラップしているhslのHH\Lib\C\contains_keyが利用できます。

これを使って実装すると次のようになります。

use namespace HH\Lib\C;

<<__Rx>>
public function has(string $id): bool {
return C\contains_key($this->map, $id);
}

シンプルなコードです。

ここまでの実装に対応しテストコードも合わせて記述しておきましょう。

*実際にはテストを書きながら実装して確認していきます

<?hh // strict

use type Acme\HackDi\Container;
use type Facebook\HackTest\HackTest;
use function Facebook\FBExpect\expect;

final class ContainerTest extends HackTest {

public function testHasIdentifierShoulbReturnBool(): void {
$container = new Container();
expect($container->has(\stdClass::class))->toBeFalse();
$container->set(\stdClass::class, ($container) ==> new \stdClass());
expect($container->has(\stdClass::class))->toBeTrue();
}
}

setメソッドの第一引数にstringが型宣言されており、

Typechecker等もありますのでそれ以外の型が入る、Exceptionが返却される、

などのテストは必要ありません。


インスタンス生成

残りはインスタンス生成のみです。

こちらもPSR-11ライクにgetメソッドで取得できるようにしてみましょう。

  <<__Rx>>

public function get(string $id): mixed {
if ($this->has($id)) {
list($scope, $callable) = $this->map[$id];
if ($callable is nonnull) {
return $callable($this);
}
}
}

実装済みのhasメソッドを内部でコールし、

コンテナに登録済みの場合は、callbackで実行します。

非常にシンプルな実装です。

$this->map[$id] で取得される値はsetメソッド実装時に利用したtupleで、

Typecheckerが認識できるものですので、

値がtupleであるかどうかを確認する必要はありません。

と同時にtupleを利用したものはlistが利用できますので、

list($scope, $callable) となります。

listの内部もTypecheckerが判断していますので、

PHPのように $scopeがEnumsかどうか、なども必要ありません。

誤った値を混入させたい、

といったことも基本的にTypecheckerが理解できる範囲であれば

errorとなりますので、不確実な値が入らないようになっています。

この辺りは静的型付け言語にかなり近いと感じるポイントではないでしょうか?

下記のものはHackのみの記法で、

null出ないことを条件として調べる演算子です。

$callable is nonnull

nullでなければcallableなものが渡ってくる、という処理が保証されています。

かつ、callableの型が (function(\Acme\HackDi\Container): mixed); となっていますので、

$callable($this) で問題ない、とTypechekcerでも認識されます。

これ以外の引数を動的にコールバックに含めることはできません。

利用する場合は、callableの型に追記する必要があります。


Singleton or Prototype?

シングルトンかプロトタイプを指定できるようにEnumsを最初に用意しました。

せっかくなので、これに対応させましょう。

シングルトンとプロトタイプの違いは下記の通りです。

Scope
挙動の違い

Singleton
一度だけインスタンスを生成し、以降はそのインスタンスを共有する

Prototype
常に新しいインスタンスを生成する

シングルトンの場合は、以前取り上げた <<__Memoize>> が利用できます。

これを利用すると以下のコードになります。

  <<__Memoize>>

protected function shared(string $id): mixed {
list($_, $callable) = $this->map[$id];
return $callable($this);
}

このメソッドを経由すると、同じインスタンスが返却されるようになります。

先ほどのgetメソッドの内部でこのメソッドをコールすれば良さそうです。

Scope Enumsを使って処理が分岐できます。

ついでにコンテナに登録されていないものを呼び出そうとした場合にExceptionをスローするように追加します。

<?hh // strict

namespace Acme\HackDi\Exception;

final class NotFoundException extends \RuntimeException {}

Acme\HackDi\Exception\NotFoundExceptionをスローするように追加し、

Scopeでインスタンス生成方法を分岐させると下記のコードになります。

  <<__Rx>>

public function get(string $id): mixed {
if ($this->has($id)) {
list($scope, $callable) = $this->map[$id];
if ($callable is nonnull) {
if ($scope === Scope::SINGLETON) {
return $this->shared($id);
}
return $callable($this);
}
}
throw new NotFoundException(
Str\format('Identifier "%s" is not binding.', $id),
);
}

strictになっていますのTypecheckerで型チェックを行っているため、

コード自体は型宣言通りに記述するだけで非常に簡単なコードのみになります。

きちんとインスタンス生成されるかテストもしっかりと記述しましょう。

Prototypeとして動作しているかどうかは下記のようなテストになります。

  public function testShouldBePrototypeInstance(): void {

$container = new Container();
$container->set(\stdClass::class, ($container) ==> new \stdClass());
$stdClass = $container->get(\stdClass::class);
expect($stdClass)->toBeInstanceOf(\stdClass::class);
expect($container->get(\stdClass::class))->toNotBeSame($stdClass);
}

Sigletonのテストは下記のようなテストになります。

  public function testShouldBeSingletonInstance(): void {

$container = new Container();
$container->set(
\stdClass::class,
($container) ==> new \stdClass(),
Scope::SINGLETON,
);
$stdClass = $container->get(\stdClass::class);
/* HH_FIXME[4064] for testing */
/* HH_FIXME[4053] for testing */
$stdClass->testing = 1;
expect($stdClass)->toBeInstanceOf(\stdClass::class);
$second = $container->get(\stdClass::class);
expect($second)->toBeSame($stdClass);
/* HH_FIXME[4064] for testing */
/* HH_FIXME[4053] for testing */
expect($second->testing)->toBeSame(1,);
}

シンプルなコンテナライブラリであればこれで十分に機能することがわかるかと思います。

非常に簡単に作ることができました。

コード全体は以下の通りです。

<?hh // strict

namespace Acme\HackDi;

use type Acme\HackDi\Exception\NotFoundException;
use namespace HH\Lib\{C, Str};
type TCallable = (function(\Acme\HackDi\Container): mixed);

enum Scope : int {
PROTOTYPE = 0;
SINGLETON = 1;
}

class Container {

private dict<string, (Scope, TCallable)> $map = dict[];

public function set(
string $id,
TCallable $callback,
Scope $scope = Scope::PROTOTYPE,
): void {
$this->map[$id] = tuple($scope, $callback);
}

<<__Rx>>
public function get(string $id): mixed {
if ($this->has($id)) {
list($scope, $callable) = $this->map[$id];
if ($callable is nonnull) {
if ($scope === Scope::SINGLETON) {
return $this->shared($id);
}
return $callable($this);
}
}
throw new NotFoundException(
Str\format('Identifier "%s" is not binding.', $id),
);
}

<<__Memoize>>
protected function shared(string $id): mixed {
list($_, $callable) = $this->map[$id];
return $callable($this);
}

<<__Rx>>
public function has(string $id): bool {
return C\contains_key($this->map, $id);
}
}

次回以降はこのコンテナを使ってさらにいろんなHackライブラリを使っていきます。

乞うご期待!