Posted at

Hackで作るdotenvライブラリ


dotenvライブラリを作ってみよう

最後のPHPサポートHHVM3.30がリリースされました。

今回からは、PHPのライブラリを使わずともHackのみで実装できるように、

Hack向けのライブラリを作ってみましょう。

今回は最近のWebアプリケーションでは利用されことがおおい dotenvです。

FOO=bar

BAR=baz

下記のライブラリを元にHackでも扱えるように移植してみましょう。

vlucas/phpdotenv

移植の一部のみ紹介します。

移植後のものはこちら


移植準備

まずはcomposerからです。

これまで紹介してきたようにHack対応にします。

composer.jsonは次のようになります。


composer.json

{

"name": "hoge/hackdotenv",
"description": "Loads environment variables from .env to getenv()",
"minimum-stability": "stable",
"require": {
"hhvm": ">=3.30.0",
"hhvm/hhvm-autoload": "^1.6",
"hhvm/hsl": "^3.30",
"hhvm/hsl-experimental": "^3.30.2"
},
"require-dev": {
"hhvm/hacktest": "^1.3",
"facebook/fbexpect": "^2.3.0",
"hhvm/hhast": ">=3.30.0"
}
}

hhvm-autoloadがcomposerのオートローダーの代わりになります。

Hack専用になりますので、PSR-4の指定等はなくても問題ありません。

つぎにhhvm-autoload用のhh_autoload.jsonです。


hh_autoload.json

{

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

hhast-lintを使いたい場合は、標準的なものであれば下記のものでしょうか。


hhast-lint.json

{

"roots": [
"src/",
"tests/"
],
"namespaceAliases": {
"HHAST": "Facebook\\HHAST\\Linters"
}
}

.hhconfigは下記のものを記述します。

assume_php=false

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


サニタイズ

下記の要素に対して作用するものを実装します。

Key=Value

まずはインターフェースを用意します。

keyとvalueを扱うため、tuple(string, string) で返却します。

<?hh // strict

namespace HackDotenv\Dotenv\Sanitize;

<<__Sealed(SanitizeName::class, SanitizeValue::class)>>
interface SanitizeInterface {

public function sanitize(
string $name,
string $value
): (string, string);
}

Sealedとして、指定したクラス以外の実装は制限します。

Keyの処理は下記の様になります。

<?hh // strict

namespace HackDotenv\Dotenv\Sanitize;

use namespace HH\Lib\Str;
use function str_replace;

class SanitizeName implements SanitizeInterface {

public function sanitize(
string $name,
string $value
): (string, string) {
$name = Str\trim(str_replace(['export ', '\'', '"'], '', $name));
return tuple($name, $value);
}
}

valueの部分は正規表現を利用して処理を行います。

PHPでの実装は次の通りです。

    protected function sanitiseVariableValue($name, $value)

{
$value = trim($value);
if (!$value) {
return array($name, $value);
}
if ($this->beginsWithAQuote($value)) { // value starts with a quote
$quote = $value[0];
$regexPattern = sprintf(
'/^
%1$s # match a quote at the start of the value
( # capturing sub-pattern used
(?: # we do not need to capture this
[^%1$s\\\\]* # any character other than a quote or backslash
|\\\\\\\\ # or two backslashes together
|\\\\%1$s # or an escaped quote e.g \"
)* # as many characters that match the previous rules
) # end of the capturing sub-pattern
%1$s # and the closing quote
.*$ # and discard any string after the closing quote
/mx'
,
$quote
);
$value = preg_replace($regexPattern, '$1', $value);
$value = str_replace("\\$quote", $quote, $value);
$value = str_replace('\\\\', '\\', $value);
} else {
$parts = explode(' #', $value, 2);
$value = trim($parts[0]);
// Unquoted values cannot contain whitespace
if (preg_match('/\s+/', $value) > 0) {
// Check if value is a comment (usually triggered when empty value with comment)
if (preg_match('/^#/', $value) > 0) {
$value = '';
} else {
throw new InvalidFileException('Dotenv values containing spaces must be surrounded by quotes.');
}
}
}
return array($name, trim($value));
}

Hackの場合は、

sprintfなどを利用すると置換指示子の数もTypecheckerが働きますので、

的確に記述する必要があります。

      $reg = Str\format(

'/^
%s # match a quote at the start of the value
( # capturing sub-pattern used
(?: # we do not need to capture this
[^%s\\\\]* # any character other than a quote or backslash
|\\\\\\\\ # or two backslashes together
|\\\\%s # or an escaped quote e.g \"
)* # as many characters that match the previous rules
) # end of the capturing sub-pattern
%s # and the closing quote
.*$ # and discard any string after the closing quote
/mx'
,
$quote,
$quote,
$quote,
$quote
);

以前に紹介したhsl-experimentalの関数などを利用してHack向けに処理を置き換えていきます。

Hackで上記の処理を書き換えると以下の様になります。

<?hh // strict

namespace HackDotenv\Dotenv\Sanitize;

use type HackDotenv\Dotenv\Exception\InvalidFileException;
use namespace HH\Lib\{Str, Regex};

use function preg_replace;
use function preg_match;
use function strpos;
use function mb_substr;

class SanitizeValue implements SanitizeInterface {

public function sanitize(
string $name,
string $value
): (string, string) {
$value = Str\trim($value);
if (!$value) {
return tuple($name, $value);
}
if ($this->isQuote($value)) {
$quote = $this->firstChar($value);
$reg = Str\format(
'/^
%s # match a quote at the start of the value
( # capturing sub-pattern used
(?: # we do not need to capture this
[^%s\\\\]* # any character other than a quote or backslash
|\\\\\\\\ # or two backslashes together
|\\\\%s # or an escaped quote e.g \"
)* # as many characters that match the previous rules
) # end of the capturing sub-pattern
%s # and the closing quote
.*$ # and discard any string after the closing quote
/mx'
,
$quote,
$quote,
$quote,
$quote
);
$value = preg_replace($reg, '$1', $value)
|> Str\replace($$, "\\$quote", $quote)
|> Str\replace($$, '\\\\', '\\');
return tuple($name, $value);
}
$p = Str\split($value, ' #', 2);
$value = Str\trim($p[0]);
if (Regex\matches($value, re"/\s+/")) {
if (!Regex\matches($value, re"/^#/")) {
throw new InvalidFileException('values containing spaces must be surrounded by quotes.');
}
$value = '';
}
return tuple($name, $value);
}

<<__Rx>>
protected function isQuote(string $value): bool {
return strpos($value, '"') === 0 || strpos($value, '\'');
}

protected function firstChar(string $value): string {
return mb_substr($value, 0, 1);
}
}

mb_substrはreactiveにはできないため、strposを使っているメソッドのみ <<__Rx>> と指定できます。

PHPの処理をHackならではの記法などに置き換えました。


File Loader

.envファイルを読み込んで値の成形を行う処理です。

ensureメソッドは、ファイルが存在するかどうかを確認するメソッドです。

PHPの実装と大きくかわりません。

Hackで置き換える場合は、主に下記の様なフィルター処理です。

  public function __construct(

protected string $filePath,
protected SanitizeName $sn,
protected SanitizeValue $sv
) {}

public function load(): void {
$this->ensure();
$rows = Vec\filter(
$this->readFile($this->filePath),
($row) ==> !$this->isComment($row) && $this->isAssign($row)
);
foreach($rows as $row) {
$this->setEnvVariable($row);
}
}

readFileメソッドはファイルを読み込み、vec<string>へと変換します。

こうすることでhslなどのコレクション操作系の関数を利用できます。

  protected function readFile(string $filePath): vec<string> {

$autodetect = ini_get('auto_detect_line_endings');
ini_set('auto_detect_line_endings', '1');
$lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
ini_set('auto_detect_line_endings', $autodetect);
return vec($lines);
}

コメントの判定などはRxと指定できます。

  <<__Rx>>

protected function isComment(string $line): bool {
return Str\starts_with(Str\trim_left($line), '#');
}

<<__Rx>>
protected function isAssign(string $line): bool {
return strpos($line, '=') !== false;
}

setEnvVariableは、読み込んだ.envファイル内のkey, valueを分割してputenvで値を設定します。

サニタイズの処理はこの時に利用する様にします。

分割とサニタイズの処理は次の様になります。


public function filters(string $name, string $value): (string, string) {
list($name, $value) = $this->split($name, $value)
|> $this->sn->sanitize($$[0], $$[1])
|> $this->sv->sanitize($$[0], $$[1]);
return tuple($name, $value);
}

protected function split(string $name, string $value): (string, string) {
if (strpos($name, '=') !== false) {
$a = Vec\map(
Str\split($name, '=', 2),
($v) ==> Str\trim($v)
);
return tuple(strval($a[0]), strval($a[1]));
}
return tuple(strval($name), strval($value));
}

細かい処理はスキップしますが、

あとはファイル読み込み時にサニタイズのインスタンスを指定できる様にすると、

.envファイルのパースとputenv等が実行され、値を取得できる様になります。

<?hh // strict

namespace HackDotenv\Dotenv;

use namespace HH\Lib\Str;
use type Ytake\Dotenv\Exception\InvalidPathException;
use type Ytake\Dotenv\Sanitize\SanitizeName;
use type Ytake\Dotenv\Sanitize\SanitizeValue;

use const DIRECTORY_SEPARATOR;

<<__ConsistentConstruct>>
class Dotenv {

protected Loader $loader;

public function __construct(
string $path,
string $file = '.env'
) {
$this->loader = new Loader(
$this->getFilePath($path, $file),
new SanitizeName(),
new SanitizeValue()
);
}

public function load(): void {
$this->loadData();
}

// 省略
}

Laravelのenvヘルパー関数ライクなものを用意する場合は下記の様になります。

use namespace HH\Lib\Str;

use function getenv;

function env(string $key, ?string $default = null): mixed {
$value = getenv($key);
if ($value === false) {
return $default;
}
switch (Str\lowercase($value)) {
case 'true':
return true;
case 'false':
return false;
case 'empty':
return '';
case 'null':
return null;
}
return $value;
}


テストを少しだけ

HackTestの書き方で紹介したものを使って記述します。

PHPUnitのsetUpメソッドと同等のメソッドを使ってテスト対象のインスタンス生成を行いますが、

Hackでは基本的にコンストラクタでプロパティに代入されないものは基本的にnullableにしなければなりません。

一般的に考えれば、クラスのインスタンス生成時にプロパティに値が入る保証がないた当然ですね

このため下記の例では、

テスト対象のディレクトリに利用するdirプロパティは ?string となります。

<?hh // strict

use type HackDotenv\Dotenv\Dotenv;
use type Facebook\HackTest\HackTest;
use type HackDotenv\Dotenv\Exception\InvalidPathException;

use function dirname;
use function getenv;
use function Facebook\FBExpect\expect;

final class DotenvTest extends HackTest {

private ?string $dir;

private Vector<string> $v = Vector{
'FOO', 'BAR', 'INT', 'SPACED', 'NULL', 'IMMUTABLE'
};

<<__Override>>
public async function beforeEachTestAsync(): Awaitable<void> {
$this->dir = dirname(__DIR__) . '/tests/resources';
$this->v->map(($v) ==> putenv($v));
}

<<ExpectedException(InvalidPathException::class)>>
public function testShouldThrowInvalidPathException(): void {
$dotenv = new Dotenv(__DIR__);
$dotenv->load();
}

public function testDotenvLoadsEnvironmentVars(): void {
invariant($this->dir is string, "error");
$dotenv = new Dotenv($this->dir);
$dotenv->load();
expect(getenv('FOO'))->toBeSame('bar');
expect(getenv('BAR'))->toBeSame('baz');
expect(getenv('SPACED'))->toBeSame('with spaces');
expect(getenv('NULL'))->toBeEmpty();
}

public function testShouldNotOverwriteEnv(): void {
putenv('IMMUTABLE=true');
invariant($this->dir is string, "error");
$dotenv = new Dotenv($this->dir, 'imm.env');
$dotenv->load();
expect(getenv('IMMUTABLE'))->toBeSame('true');
}

public function testShouldGetEnvList(): void {
invariant($this->dir is string, "error");
$dotenv = new Dotenv($this->dir);
$dotenv->load();
expect($dotenv->getEnvVarNames())->toBeInstanceOf(Vector::class);
expect($dotenv->getEnvVarNames())->toContain('FOO');
expect($dotenv->getEnvVarNames())->toContain('BAR');
expect($dotenv->getEnvVarNames())->toContain('INT');
expect($dotenv->getEnvVarNames())->toContain('SPACED');
expect($dotenv->getEnvVarNames())->toContain('NULL');
}
}

お気に入りのPHPライブラリをHackに置き換えて、Hackに慣れていきましょう!