type holyshared = Engineer<mixed>

PHP、Hack、Ruby、OCaml、Rust、Javascript周りの技術ブログ

Hack用のコードジェネレータの新しいバージョンをリリース

Hack用のコードジェネレータの新しいバージョンをリリースしました。
前のバージョンでは、デフォルトのジェネレータしか使用できなかったのを独自のジェネレータを使用できるようにしました。

サンプルのプロジェクト下記に用意してあります。
https://github.com/holyshared/hhpack-codegen-example

利用する為の設定

composer.json

hhpack/codegenを開発用のパッケージに追加します。

"require-dev": {
  "hhpack/codegen": "^0.2.0"
},

hh_autoload.json

ジェネレータの設定が読み込めるように、devRootsにパスを追加します。
パスが間違っていると、設定が読み込めないので注意してください。

{
  "roots": "src",
  "devRoots": "/path/to/" // 設定ファイルがあるパス
}

ジェネレータの設定ファイルを追加する

名前空間に対して、対応するジェネレータをマッピングするのは変わっていないです。
今回のバージョンではジェネレータに対して、名前と説明をメタ情報と持たせることができます。
このメタ情報は、コマンドラインからジェネレータを指定するのに使用します。

下記の例ではジェネレータにそれぞれ、package:class、package:testclassという名前をつけています。

<?hh //strict

namespace HHPack\Example\Generators;

use HHPack\Codegen\Cli\{ DefinedGenerator };
use HHPack\Codegen\Contract\{ GeneratorProvider };
use HHPack\Codegen\HackUnit\{ TestClassGenerator };
use HHPack\Codegen\Project\{ PackageClassGenerator };
use function HHPack\Codegen\Cli\{ namespace_of, define_generator };

final class Generators implements GeneratorProvider {
  public function generators(): Iterator<DefinedGenerator> {
    // Link package namespace to generator
    yield define_generator('package:class', 'generate class file for package')
      ->mapTo(
        namespace_of('HHPack\Example', 'src')->
          map(PackageClassGenerator::class)
      );

    // Link package test namespace to generator
    yield define_generator('package:testclass', 'generate test class file for package')
      ->mapTo(
        namespace_of'HHPack\Example\Test' 'test')->
          map(TestClassGenerator::class)
      );
  }
}

コードの生成

コードを生成するにはコマンドラインからジェネレーターを指定します。
利用できるジェネレータは、ヘルプで確認できます。

vendor/bin/codegen -h

Usage: codegen [OPTIONS] [GEN] [NAME]

Arguments:
   GEN: generator name (ex. lib, test)
    package:class       generate class file for package
    package:testclass   generate test class file for package

  NAME: generate class name (ex. Foo\Bar)

Options:
  -h, --help     Display help message
  -v, --version  Display version

今回の例だと、package:class、package:testclassを利用できます。

vendor/bin/codegen package:class [CLASS_NAME]
vendor/bin/codegen package:testclass [CLASS_NAME]

例えば下記の例だと、src/FooBarのクラスファイルを生成します。

vendor/bin/codegen package:class Foo\\Bar

独自ジェネレータ

独自のジェネレータを使用したい場合は、インタフェースのHHPack\Codegen\Contract\ClassFileGeneratableを実装すればいいです。
詳しくはデフォルトで組み込まれているソースを参考にしてください。

use HHPack\Codegen\{GenerateClassFile};
use HHPack\Codegen\Contract\{ClassFileGeneratable};
use Facebook\HackCodegen\{ICodegenFactory, CodegenFile, CodegenClass};

final class MyGenerator implements ClassFileGeneratable {
  public static function from(ICodegenFactory $factory): this {
    return new self($factory);
  }

  public function generate(GenerateClassFile $target): CodegenFile {
  }
}

HackでのJSONのパース

HackでJSONをパースする際にjson_decodeを使うと思うのですが、JSON_FB_HACK_ARRAYSが新しく指定できるようになったみたいなので調べて見ました。

また、JSONを扱う場合の実装をどうするかも検討しました。

json_decodeのオプションによる型の違い

オプションなし

オプションを指定しない場合、パースした結果はarray型になるようです。

function test1(): array<string, mixed> {
  $j = file_get_contents("test.json");
  $v = json_decode($j, true);
  var_dump($v);
  return $v;
}

出力

array(2) {
  ["name"]=>
  string(3) "Bob"
  ["group"]=>
  array(1) {
    ["name"]=>
    string(9) "HHVM/Hack"
  }
}

JSON_FB_COLLECTIONS

このオプションの場合、パースした結果はMap型になるようです。

// JSON_FB_COLLECTIONS
function test2(): Map<string, mixed> {
  $j = file_get_contents("test.json");
  $v = json_decode($j, true, 512, JSON_FB_COLLECTIONS);
  var_dump($v);
  return $v;
}

出力

object(HH\Map)#1 (2) {
  ["name"]=>
  string(3) "Bob"
  ["group"]=>
  object(HH\Map)#2 (1) {
    ["name"]=>
    string(9) "HHVM/Hack"
  }
}

JSON_FB_HACK_ARRAYS

このオプションの場合、パースした結果はdict型になるようです。
JSONの構造が静的解析時にわからないので、dict<string, mixed>扱いになりそうです。

// JSON_FB_HACK_ARRAYS
function test3(): dict<string, mixed> {
  $j = file_get_contents("test.json");
  $v = json_decode($j, true, 512, JSON_FB_HACK_ARRAYS);
  var_dump($v);
  return $v;
}

出力

dict(2) {
  ["name"]=>
  string(3) "Bob"
  ["group"]=>
  dict(1) {
    ["name"]=>
    string(9) "HHVM/Hack"
  }
}

通常はどうするか

普通にJSONを扱うコードを書く分には、次のようにするのをお勧めします。

  1. 構造の表現にshapeを使用する
  2. type-assertを使用する

1. 構造の表現にshapeを使用する

dict、array、Mapで型指定してしまうと、どうしてもmixedを使用する羽目になるので、タイプチェッカーと相性が悪くなります。
mixedはnullを含む、全てのものが値として考えられるので、極力使うべきではないと思います。 代わりにJSONのフォーマットをshapeで表現します。

/**
 * 次のようなJSONの場合
 *
 * {
 *   "name": "Bob",
 *   "group": {
 *     "name": "HHVM/Hack"
 *   }
 * }
 */

type GroupJSON = shape(
  "name" => string
);

type UserJSON = shape(
  "name" => string,
  "group" => GroupJSON
);

shapeにした場合、定義されていないフィードに対してのアクセスは静的解析時に検出することができます。
パースした結果を参照したりするコードに非常に有効です。

2. type-assertを使用する

type-assertパッケージを使用して、実行時にチェックを行うようにします。 https://github.com/hhvm/type-assert

パースするJSONをファイルから読み込んだりする場合、期待する構造のものかは実行時にしか基本的にはわからないため、静的解析時の問題検出は諦めて、実行時に検出する方針です。

TypeAssert\matches_type_structureTypeStructure<T>と検査対象の値を受け取り、値が型の定義を満たしているか検証します。
値が仕様を満たしている場合は、Tの値をそのまま返します。

TypeStructure<T>を得るにはtype_structure関数を使用します。
第1引数にclass名、またはオブジェクト、第2引数にはType Constantの名前を指定します。

この例では、JSON形式のサーバーの設定を読み込む例です。

function server_config_from_json(string $path): ServerConfiguration {
  $content = file_get_contents($path);
  $rawConfig = json_decode($content, true, 512, JSON_FB_HACK_ARRAYS);
  $validConfig = TypeAssert\matches_type_structure(
    type_structure(ServerConfiguration::class, 'T'),
    $rawConfig
  );
  return new ServerConfiguration($validConfig);
}

ServerConfigurationの定義はこうなっています。

//JSONの設定ファイル
interface JSONConfiguration {
  abstract const type T; //ここの型はインターフェースを実装するクラスで指定する
}

final class ServerConfiguration implements JSONConfiguration {
  //JSONのスキーマを指定する
  const type T = shape(
    "host" => string,
    "port" => int
  );

  public function __construct(private this::T $json) : void
  {
  }

  public function host() : string {
    return $this->json['host'];
  }

  public function port() : int {
    return $this->json['port'];
  }
}

困りごと

TypeStructure<T>は基本的にtype_structure関数でしか、生成できない見たいです。
ReflectionTypeAliasのgetTypeStructureメソッドでいけないかなと思ったのですが、hhiファイルの定義上はarrayを返すようになっていて、 TypeStructure<T>ではないとタイプチェックでエラーになりました。

TypeStructure<T>はshapeなので、shapeを生成するコードを書くしかないぽいです。

Scheme手習い読み終わった

定理証明手習いを途中まで読んでいたのですが、読む前におすすめされていたのと、近所の本屋に一冊だけあったので読みました。

最初にちょっとだけ読み始めて、載っているコードが非常に少なく、写経しなくても覚えれそうと思い、 ちょうど真ん中ぐらいの内容まで、読むだけにしていました。

しかし、だんだん読むのに飽きてきたので、写経するように変えていくようにしました。

本の内容

基本的にSchemeになれるようにするためのドリルぽい印象です。
実用的なコードはないですね。

  • 7割ぐらいリスト操作
  • 再帰するコードがひたすら出てくる
    • OCamlとかやっている人はそんなに難しくない
  • ドリル形式、読み手に質問しつつ、書く関数の仕様をはっきりさせていくスタイル
    • 仕様わかったよね?、もう書けるよね?、じゃあ書いてみようのステップ
  • cond, car, cdr, consをよく使う、リストの再帰だから仕方ない
  • 1つの関数が大したことないので、サクサク進める

写経の仕方

写経するに準備したもの

用意したのはSchemeの処理系だけです。 SchemeLISPはやったことなかったので、インストールが楽そうなRacketにしました。

写経の仕方

Githubリポジトリ作って、写経したその日にやったことをREADME.mdに雑に書いていく感じのスタイルです。

コードは本の内容を移すのではなく、期待する動作をするコードを書いていきました。
なので、本のこういう関数に引数x,yを与えると、結果はどうなるか、という仕様部分しか見てないです。

そして、書いた結果と本のコードを見比べて、条件が足りているか答え合わせをする感じです。

感想

Scheme入門的にはいいかなと思います。

プログラミングやったことない人にはいいかもしれないですが、ただ、内容が割と単調なので、途中で飽きる人出そうな気はします。

ちなみにSchemeやったことなかった人の意見です。

OCamlと比べて、Scheme再帰が書きにくいと感じました。

これは、OCamlでListの再帰を書くとき、自分は普段パターンマッチを使用していて、特定の条件に対して、それぞれコードを書いていくスタイルなのですが、Schemeの場合はリストが空の場合はこう、リストの先頭がこれの場合こうと手順を意識しないといけない印象を受けた為です。

この例は、あんまりよくないです。

OCaml

(* [] ->, hd::tail ->の順番を変えても問題ない *)
let print_all l =
  let rec print_all l =
    match l with           (* matchじゃなくて、functionの方がいいかも *)
      | [] -> ()           (* 空の場合 *)
      | hd::tail ->        (* 空でない場合 *)
        print_endline hd;
        print_all tail in
  print_all l

Scheme

(define print_all
  (lambda (l)
    (cond 
      ((null? l) values)         ; null?から先に確認
      (else (begin
        (display (car l))
        (newline)
        (print_all (cdr l)))))
))

書いた方が覚えやすいです

途中から写経し始めたわけですが、読んでいるだけだと記憶の定着率が悪いのを再認識しました。
書けるだろうと、書き始めて見ても、もどう書けばいいのかなかなか頭から出ませんでした。

ppx_inline_testでモジュール内にテストコードを書く

これはML Advent Calendar 2017の記事です。
枠が空いていて、誰も書いてなかったので書きました。

OCamlのテスト用のツールで、構文拡張を使用したppx_inline_testがあります。
今まで、OUnitを使っていたのですが試しに使用してみました。

使用するのに必要なライブラリは下記の通りです。

  • ppx_jane - Jane Streetが公開している構文拡張ライブラリ群
    • ほんとは必要ないパッケージがあるので、使いたくありませんでした(ビルドが面倒だったので使いました。)
  • jbuilder - Jane Streetが公開しているOCamlのビルドツール

サンプルのプロジェクトは下記のリポジトリに用意してあります。
ppx_inline_test_with_jbuilder

OPAM用のファイルを用意する

ルートディレクトリにライブラリ用のOPAMファイルを用意し、 build、build-testにビルド用のコマンドを追加します。

そのほかの項目はメタ情報を参照して、必要な物を追加してください。

opam-version: "1.2"
name: "example"
version: "0.1.0"
authors: ["YOUR NAME"]

build: [
  ["jbuilder" "build" "-p" name "-j" jobs]
]

build-test: [
  ["jbuilder" "runtest" "-p" name]
]

depends: [
  "jbuilder" {build}
]
available: [ ocaml-version >= "4.06.0" ]

テストコードを含んだライブラリの用意

ライブラリの用意

src/exampleディレクトリを作成して、example1.mlというファイル名でライブラリのコードをおきます。

ここでは、say関数を定義して、その関数のテストコードを用意してます。
このテストはわざと落ちるようにしています。

またtest_unitはテストがパスした場合は()、失敗した場合は例外(exn)を投げる必要があります。

let say () = "hello"

let%test_module _ = (module struct
  let%test_unit "say hello" =
    assert (say () = "world")
end)

ライブラリのビルド設定

ビルド用のファイルjbuildを用意します。

library_flags-linkallを指定します、これは指定しないと、テストモジュールがリンクされないです。

また、preprocessppx_jane, ppx_driver.runnerを指定して、テストコードの構文拡張を処理できるようにします。

(jbuild_version 1)

(library (
  (public_name example.example1)
  (name example_example1)
  (library_flags -linkall)
  (preprocess (pps (ppx_jane ppx_driver.runner)))
))

テストランナーの用意

テストランナーのコード追加

testディレクトを作成して、テストランナーのコードを用意します。
ファイル名はtest_runner.mlにします。

テストランナーのソースには下記のコードを追加します。

Ppx_inline_test_lib.Runtime.exit ()

テストのランナーのビルド設定

まずテスト対象のライブラリをまとめたライブラリをビルドできるようにします。
そして、executableでテストランナーの依存ライブラリに加えます。

その後に、aliasruntestを追加します。
test_runner.exeのサブコマンドでinline-test-runnerを指定し、次にテスト対象のライブラリ名を指定します。

(library (
  (name example_all)
  (libraries (example.example1))
  (library_flags -linkall)
  (preprocess (pps (ppx_jane ppx_driver.runner)))
))

(executable (
  (name test_runner)
  (libraries (ppx_inline_test.runner.lib example_all))
))

(alias
  ((name runtest)
  (deps (test_runner.exe))
  (action (run ${<} inline-test-runner example_example1))))

(jbuild_version 1)

実際にテスト実行する際に、実行されるコマンドは下記の通りです。
ライブラリ名は、パブリックな名前だと実行できませんでした。

_build/default/test/test_runner.exe inline-test-runner example_example1

テストの実行

各手順が終われば、下記のコマンドを実行するとテストが実行でき、テストが落ちるはずです。

jbuilder runtest

落ちたのが確認できた場合、今度はライブラリのコードを修正して、テストが通ることを確認してみてください。

感想

テストが実行できるようになるまで、結構時間がかかりました。
特に-linkallフラグを指定していなくて、テストが実行できなかったのにハマりました。

また、ppx_assertを使いたかったのですが、テストモジュールに別途必要なモジュールをopenしないとコンパイルが通らなくて、若干面倒でした。

モジュールにテストコードを直接かけるのはいいですが、OUnitの方がわかりやすいかなーといいう印象です。

Typesafetyの0.10.0をリリースした

Typesafetyの0.10.0をリリースし、下記の変更を加えました。

  1. OASISからjbuilderへの移行
  2. タイプチェックがパスした時も、レビューコメントを残せるようにした

OASISからjbuilderへの移行

ビルドツールをOASISからjbuilderへ変更しました。
移行作業自体は1日で終わり、案外楽に行えました。

手順は下記の通りです。

  1. OASISの設定を残しつつ、各ライブラリのビルド設定を行う。
  2. openしているモジュール名をリネームする。
  3. jbuilderで全てのライブラリのビルド、実行ファイルがコンパイルできることを確認する。
  4. テストファイルもライブラリと同じく、jbuilderコンパイル & 実行できるようにする。
  5. OASISで必要だったファイルを削除する。
  6. *.opamファイルを変更する。

移行後にテスト自体がこけるようになり、そこだけ時間がかかりました

テストコードはテスト用にレポートのフィクスチャを用意して、生成したレポート結果と比較するようなテストコードだったのですが、テストの実行ファイルが生成される場所が変わったため、フィクスチャファイルが読めなくなっていました。

テストの実行ファイルは_build/default/testsに生成され、フィクスチャファイルはtests/fixturesに置いていたので、相対パスで読めなくなったのが原因です。

解決方法としてはjbuildにフィクスチャファイルをコピーする設定を追加しました。
prognを追加して、ファイルをコピーする設定にしました。

パスは_build/default/testsからの相対パスで指定し、ディレクトリごと_build/default/testsにコピーします。

(alias (
  (name runtest)
  (deps (test.exe))
  (action (
    progn
      (run cp -R ../../../examples .)
      (run cp -R ../../../tests/fixtures .)
      (run ${<})
  ))
))

タイプチェックがパスした時も、レビューコメントを残せるように

今まではタイプエラーがある時のみ、結果をPRのコメントとして投稿していましたが、エラーがなくてもコメントを投稿するようにしました。

理由としては、タイプエラーがドッグフーディング中にほとんど発生しない為、正しく動いているのか判断できないことが多かったからです。

タイプエラーがない時にもコメントを残すことで、PR出した人もタイプエラーがないことをCIのログを見て確認する必要もないですし、正常に機能していることを確認することができます。

エラーがある時だけコメントを残したい場合は、--skip-passedを使用することで、スキップできます。

typesafety --review --skip-passed

移行後の感想

事前にjbuilderをある程度試していたので、思ったり簡単に移行することができました。
またタイプエラーがない時にも、コメントを残すようになったので、ほとんどマージなどもGitHubで完結できるようになったのは良かったです。

次は後もう少しでOCamlの4.06.0が出そうなので、そのチェックが必要です。

jbuilderでビルドに必要なファイルを生成する

jbuilderでビルドに必要なファイルを生成する方法を調べました。
なんで調べたかというと、atdgenjsonのパーサーのコードを出力したかったからです。

パッケージのインストール

jbuilderatdgenをインストールします。

opam install atdgen jbuilder

ruleをjbuildファイルに追加する

次のようなJSONスキーマファイルがある場合、jbuildファイルにruleを追加するだけでOKです。
スキーマファイルはuser.atdします。(拡張子はなんでもいいはず)

スキーマファイル

type user = {
  name: string;
  description: string;
}

jbuildファイル

targetsに生成されるファイルを指定して、depsに依存しているファイルを指定します。
また、actionで実行するコマンドを指定します。

パラメータ

パラメータ 説明
\${@} targetsで指定したファイルリスト
\${<} depsで指定したファイルリスト
\${bin:} プログラム名、PATHなどからプログラムを検索する

他にもあるので、variables-expansionを参照してください。

JSONパーサーの生成

JSONパーサーを生成するruleは下記の通りです。

(rule (
  (targets (user_j.ml user_j.mli))
  (deps (user.atd))
  (action (run ${bin:atdgen} -j ${<}))
))

実際に発行されるコマンドは下記の通りです。

atdgen -j user.atd 

型の定義ファイルの生成

型の定義ファイルの生成するruleは下記の通りです。

(rule (
  (targets (user_t.ml user_t.mli))
  (deps (user.atd))
  (action (run ${bin:atdgen} -t ${<}))
))

実際に発行されるコマンドは下記の通りです。

atdgen -t user.atd 

jbuildファイルのlibrariesを追加する

jbuildファイルのlibrary設定のlibrariesにatdgenを指定します。

(library (
  (public_name user)
  (name user)
  (libraries (atdgen))
))

また、user.mlファイルには生成されたコードを利用するようにしてみます。
User_tは生成される予定のモジュールです。

let make ~name ~description =
  User_t.({ name=name; description=description })

ビルドできるか試す

ビルドコマンドでビルドできるか試します。

jbuilder build

Unbound moduleなどのコンパイルエラーが出なければ問題ないはずです。
生成されたコードは_buildのサブディレクトリにオブジェクトファイルなどと一緒に出力されるようで、 コードの管理からは外れていて問題ないようです。

実際に試したコードは下記にあります。
example_jbuilder_with_atdgen

typesafety_cliのビルドツールの変更計画

CI上でテスト前の型チェックのエラーをGitHubのPRのコメントとして投稿するツールtypesafety-cliのビルドツールを変えようとしています。

具体的にはOASISからjbuilderに移行しようとしています。
開発当初はOASISで良かったのですが、以下の理由で辛く感じるようになってきました。

  1. 内部的なライブラリのビルド設定を1つのファイルに書く必要がある。
  2. 新しくライブラリを追加する際に_oasisを別途編集しなければならない。
  3. _oasisメタデータから、opam用のファイルをopam2oasisで生成するが面倒である。
  4. _oasisと.opamファイルの内容重複しているものが多い。

jbuilderはファイルをビルド単位で分割できる

下記の2つに関してjbuilderはファイルを分割できます。
またライブラリを分割したくなっても、ビルド用のファイルを追加するだけなので、変更の差分が基本的に追加だけになりそうである。

  1. 内部的なライブラリのビルド設定を1つのファイルに書く必要がある。
  2. 新しくライブラリを追加する際に_oasisを別途編集しなければならない。

.opamファイル一つでいい

OASISの場合、_oasisファイルと.opamファイルが必要になりますが、jbuilderの場合は.opamファイルだけで良いので、 リリース作業もOASISと比べて楽になりそうである。

自動化すればいんですが、あんまり自動化のための作業を増やしくたくないです。

  1. _oasisメタデータから、opam用のファイルをopam2oasisで生成するのが面倒である。
  2. _oasisと.opamファイルの内容重複しているものが多い。

OCamlのビルドツール

OCamlのビルドツールはjbuilderjengaocamlbuildOMakeOASISなどいろいろありますが、 これ使っておけばOK見たいなのがあるといいですね。

今だとOMakejbuilderになりそうですが。
OASIS使用しているプロジェクト減っている気がします。