駅メモ!開発基盤チームの id:xztaityozx です!
皆さんは Perl を書いていますか?モバイルファクトリーが長く提供しているサービスなどでは、バックエンドが Perl で書かれています。
しかしながら、自分は普段インフラ領域をやらせてもらっているということもあり、Perl で新機能開発をする!といった機会がそんなにありません。
せっかく Perl だらけの環境にいるのに、あんまり Perl に触れられないのはもったいないな〜と思い、今年のゴールデンウィークは PPI を使ったメタプログラミングで遊んでいました。
で、ちょっと遊んでいたら Perl のパッケージ情報を使って C#のクラスを吐き出すプログラムができたので記事にしてみました。自由研究発表という感じです。
変換先に C#を選んだのは C#が大好きだからです。
Perl パッケージを C#クラスにする例
研究をしていたリポジトリは以下のものです。リポジトリ名気に入ってます。ガッっと書いたのでめちゃめちゃ雑です。
README にも書いてあることですが、以下のような Perl パッケージがあるとき
package My::Namespace::B; use strictures 2; use Function::Parameters; use Function::Return; use Types::Standard -types; use Data::Validator; use Mouse; has name => (is => 'ro', isa => Str, default => 'this is name'); has age => (is => 'ro', isa => Int, required => 1); has union => (is => 'ro', isa => Str|Int, required => 1); has dict => (is => 'ro', isa => Dict[name => Str, age => Int], required => 1); no Mouse; __PACKAGE__->meta->make_immutable; sub a { return 10; } fun b() :Return(Int) { return 10; }; method c() :Return(Int) { return 10; }; method d(Str $str, $x, Int $y //= 1) { return 10; }; sub e { my $rule = Data::Validator->new( str => { isa => Str }, x => { isa => Any }, y => { isa => Int, default => 1 }, ); $rule->validate(@_); return 10; } sub f :Return(Str, Int) { my ($self, $str, $x, $y) = @_; return [$str, $x+$y]; } sub g { return; } sub h { my $a = shift; return 1; } 1;
以下のような C#コードが生成されます。基礎部分は NJsonSchema を使って JSON Schema から生成し、メソッド部分はシグネチャだけ移植したようなコードになります。そこそこ長くなるので一部省略しております。
//---------------------- // <auto-generated> // Generated using the NJsonSchema v10.9.0.0 (Newtonsoft.Json v9.0.0.0) (http://NJsonSchema.org) // </auto-generated> //---------------------- namespace My.Namespace { #pragma warning disable // Disable all warnings [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.9.0.0 (Newtonsoft.Json v9.0.0.0)")] public partial class B { /// <summary> /// original Perl type: Int /// </summary> [System.Text.Json.Serialization.JsonPropertyName("age")] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public int Age { get; set; } /// <summary> /// original Perl type: Dict[age=>Int,name=>Str] /// </summary> [System.Text.Json.Serialization.JsonPropertyName("dict")] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public Dict Dict { get; set; } = new Dict(); /// <summary> /// original Perl type: Str /// </summary> [System.Text.Json.Serialization.JsonPropertyName("name")] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public string Name { get; set; } = "this is name"; /// <summary> /// original Perl type: Str|Int /// </summary> [System.Text.Json.Serialization.JsonPropertyName("union")] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public Union Union { get; set; } private System.Collections.Generic.IDictionary<string, object> _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] public System.Collections.Generic.IDictionary<string, object> AdditionalProperties { // ... 省略 } ///<summary></summary> ///<returns></returns> public object a() { throw new NotImplementedException(); } ///<summary></summary> ///<param name = "str">original Perl type: Str</param> ///<param name = "x">original Perl type: Any</param> ///<param name = "y">original Perl type: Int</param> ///<returns></returns> public object e(string str, object x, int y = 1) { throw new NotImplementedException(); } ///<summary></summary> ///<returns>original Perl type: Str, Int</returns> public object f() { throw new NotImplementedException(); } ///<summary></summary> ///<returns></returns> public void g() { throw new NotImplementedException(); } ///<summary></summary> ///<param name = "a">original Perl type: Any</param> ///<returns></returns> public object h(object a) { throw new NotImplementedException(); } ///<summary></summary> ///<returns>original Perl type: Int</returns> public int b() { throw new NotImplementedException(); } ///<summary></summary> ///<returns>original Perl type: Int</returns> public int c() { throw new NotImplementedException(); } ///<summary></summary> ///<param name = "str">original Perl type: Str</param> ///<param name = "x">original Perl type: Any</param> ///<param name = "y">original Perl type: Int</param> ///<returns></returns> public object d(string str, object x, int y) { throw new NotImplementedException(); } } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.9.0.0 (Newtonsoft.Json v9.0.0.0)")] public partial class Dict { // ... 省略 } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.9.0.0 (Newtonsoft.Json v9.0.0.0)")] public partial class Union { // ... 省略 } }
やっていること
このリポジトリでやっているのはざっくりいうと以下の 2 つのことだけです。
- PPI とかを使って Perl パッケージ情報を取り出して JSON に書き出す
- C#で JSON を読んで C#の AST を構築し、できたものをファイルに書き出す
同時に 2 つの AST を読むことになったので結構混乱しました。どちらの言語もむずいです。
1. PPI とかを使って Perl パッケージ情報を取り出して JSON に書き出す
今回の研究で取り出したいと思ったのは以下の 5 つです。
- パッケージ名
- 名前空間
- 親パッケージ
- Mouse のプロパティ
- サブルーチンやメソッドのシグネチャ情報
1,2 は PPI で簡単に取り出せます。具体的には以下のように書けます。
my $package_nodes = $document->find('PPI::Statement::Package'); foreach my $package_node (@{$package_nodes}) { my @namespace = split(/::/x, $package_node->namespace); my $name = pop @namespace; }
3 の親パッケージは @ISA
を見るだけ、4 のプロパティは PPI で宣言を探しつつ、Mouse のメタ情報から情報を取り出せばよいですね。
# 親パッケージを取り出す no strict 'refs'; my @superclasses = @{ $class_name . '::ISA' }; use strict 'refs'; # プロパティの宣言を探して、プロパティ名を取り出す。 my $has_statements = $ppi_document->find( sub { my ($root, $node) = @_; if ($node->isa('PPI::Token::Word') && $node->content eq 'has') { return 1; } return 0; } ); # メタ情報から名前が一致するプロパティの情報を取り出す # こうしておくと継承してたりMouseがはやしたりしたやつを省ける(別の方法はないのでしょうか…?) my $meta = Mouse::Util::get_metaclass_by_name($package_statement->namespace); my @properties; foreach my $has_statement (@{$has_statements}) { my $property_name = $has_statement->snext_sibling->string; my $attr = $meta->get_attribute($property_name); push @properties, $attr; }
ここまでは順調ですが、問題は 5 のサブルーチン・メソッドのシグネチャ情報ですね…。
Function::Parameters のメタ情報からシグネチャ情報を取り出してみる
Perl にはサブルーチンやメソッドのシグネチャをサポートするようなモジュールが沢山あります。たとえば Type::Params や Function::Parameters などですね。 こういうモジュールは大体の場合メタ情報が存在するため、そこから欲しい情報を取り出すことができます。
# Function::Parametersの例 use strictures 2; use Function::Parameters; use Types::Standard -types; method hoge($class: Int $i) { return $i; } # Function::Parameters::Info が返される。引数のリストなどが分かる my $info = Function::Parameters::info(\&hoge);
つまり、PPI でサブルーチン・メソッドを見つけ次第、メタ情報からシグネチャ情報を取り出せば良さそうです。
しかしながら、先程述べた通りこういうモジュールは沢山あるので全て対応するのは難しいです。そこでまずは Function::Parameters と Function::Return だけ注目することにしました。Function::Return は最近駅メモ!のコードでも使われるようになったので、これを機に仲良くなっておこうと思って選びました。
普通のサブルーチンからもシグネチャ情報を取り出したい
Function::Parameters や Function::Return を使って書かれているサブルーチンはいいのですが、以下のようなよくあるサブルーチンはどうしましょう。
sub hoge_sub { my $hoge_arg = shift; return $hoge_arg; }
最初、こういうのについては諦めようと思っていたんですが、PPI で遊んでいるうちに「なんとかなりそうだな」と思いました。 値の受け取りは、大体の場合以下のうちのどれかのパターンになるのでは?と考えたからです。(もちろん網羅できてるとは思っていません)
my $arg = shift; my ($arg1, $arg2) = @_; my ($arg1, $arg2, $arg3) = (shift, shift, shift);
これらの式自体は PPI::Statement::Variable
になります。children
メソッドを呼び出すと、そこにぶら下がっている子を見ることができます。
以下の例は my $arg = shift
の children
です。
[0] my (PPI::Token::Word), [1] (PPI::Token::Whitespace), [2] $arg (PPI::Token::Symbol), [3] (PPI::Token::Whitespace), [4] = (PPI::Token::Operator), [5] (PPI::Token::Whitespace), [6] shift (PPI::Token::Word), [7] ; (PPI::Token::Structure)
なんかなんとかなりそうな気がしませんか?
そうですね。右辺値の PPI::Token::Word
が shift
か @_
な PPI::Statement::Variable
から、左辺値の PPI::Token::Symbol
を取り出せば引数名がわかりますね。
残念ながら型はわからないので全部 Any になってしまいますが、今回は引数名だけでも分かればヨシ!ということでこの方針を採用しました。
Data::Validator からも引数情報を取り出したい
Data::Validator は引数のバリデーションをしてくれるモジュールです。引数名、型、制約を指定しておくことで、実際に渡ってきた値を評価してくれるものです。そうですね。これも引数の情報なのです。
メタ情報を取り出せるような仕組みがあればよかったのですが、自分が調べた限りでは見つけられませんでした。なので PPI でパースしました。機能の網羅はできてないかも…。
JSON に書き出す
ここまでで取り出した情報を JSON に書き出してみます。フォーマットを JSON Schema や ProtoBuf みたいなスキーマ定義に寄せられればよかったのですが、メソッド情報を格納するところがなくて独自になってしまいました。具体的には以下のようなフォーマットです。
{ "name": "パッケージ名", "namespace": [ "名","前","空","間" ], "schema": { パッケージを表すJSON Schema }, "methods": [ { "arguments": [ { "name": "引数の名前", "required": true, "type": { "description": "元々の型がなんだったかの説明", "type": "stringとかnumberみたいな型の名前" } }, { ... }, { ... }, ... ], "declare_type": "subとかfunとかサブルーチン定義のキーワード", "name": "関数名", "returns": { "type": "number" } }, { ... }, { ... }, ... ] }
メソッド情報が必要ないのであれば、schema
メンバーだけ使えば良いという親切設計です(?)
2. C#で JSON を読んで C#の AST を構築し、できたものをファイルに書き出す
こちらは NJsonSchema を使ってクラスの雛形を作り、 Roslyn API を使って雛形にメソッド定義を追加していくという感じです。
JSON を読んで展開していくだけなので、PPI の時ほど難しいことはありません。
var csharpGeneratorSetting = new CSharpGeneratorSettings { JsonLibrary = CSharpJsonLibrary.SystemTextJson, Namespace = string.Join(".", package.Namespace), }; var schema = await JsonSchema.FromJsonAsync("schemaプロパティの値"); var csharpGenerator = new CSharpGenerator(schema, csharpGeneratorSetting); var file = csharpGenerator.GenerateFile() ?? throw new FileNotFoundException(); using var stream = new StringReader(file); var syntaxTree = CSharpSyntaxTree.ParseText(await stream.ReadToEndAsync()); var root = await syntaxTree.GetRootAsync(); var targetClassDeclarationSyntax = root.DescendantNodes() .OfType<ClassDeclarationSyntax>() .FirstOrDefault(syntax => syntax.Identifier.ValueText == package.Name) ?? throw new NoNullAllowedException($"{package.Name} が見つかりませんでした"); // targetClassDeclarationSyntax が schema プロパティから生成したクラス // ここに methods プロパティの情報を使ってメソッド定義を追加していく // 全部書くと長いので今回は省略…。リポジトリを見てください。 var newClassDeclarationSyntax = AddMethodDeclarationSyntax(); var newRoot = root.ReplaceNode(targetClassDeclarationSyntax, newClassDeclarationSyntax); var result = syntaxTree.WithRootAndOptions(newRoot, syntaxTree.Options);
まとめ
今回は自由研究として、PPI を使った Perl パッケージ情報の解析をやってみました。解析結果を使って Perl パッケージを C#のクラスに変換しました。
結果としてそれっぽいクラスを生成できました。いまのところ今回作った生成機能をどこかで使う予定はないですが、解析結果を使えば LSP を更に便利にできるのでは?と考えています。
メタプログラミングはやはり楽しいですね。また、まとまった時間があれば研究してみたいなと思いました。
以上です。