catbuffer generator とは
Symbolではアプリゲーション側で作成したトランザクションを、ペイロード(16進数)にシリアライズし、アナウンス(PUT)します。また、REST APIから取得したデータ(16進数)をデシリアライズし活用します。
トランザクション内で使われるすべてのモデルを生成するのがgeneratorで、generatorを実行するとParserが起動しSchemaを読み込んでmodelsが生成されます。
※schemaについてはこちらが参考になります
TransferTransactionのスキーマを学習しましょう! by 松本さん
例)JavaScript
実行ファイル: https://github.com/symbol/symbol/blob/dev/sdk/javascript/scripts/run_catbuffer_generator.sh
schemas: https://github.com/symbol/symbol/tree/dev/catbuffer/schemas/symbol
parser: https://github.com/symbol/symbol/tree/dev/catbuffer/parser/catparser
parserでは言語ごとのgeneratorを使う
generator: https://github.com/symbol/symbol/tree/dev/sdk/javascript/generator
model: https://github.com/symbol/symbol/blob/dev/sdk/javascript/src/symbol/models.js
なぜgeneratorが必要なのか?モデルベタ書きでよくね?
※推測を含むので参考程度に
大きく2つ理由があると思います。
一つ目は、例えば過去にあったバージョンアップでRevocable(回収可能)なモザイクとそのトランザクションがありました。このような場合、内容はSchemaに記載されるので、ModelsのアップデートもGeneratorで再生成すれば良いだけです。
2つ目は、Schemaの文法がSymbol(Catapult)だけでなくNEM(NIS1)も対応しています。ここでのGenaratorはSymbolのためでなくこのSchemaの文法に沿ったもの全てに対応するので、同時にNEMとSymbolのModelsが生成されます。今後、同じSchemaの文法に沿ったブロックチェーンが生まれれば、Modelsはそのブロックチェーンも同時に生成されるため、SDK制作が非常に捗ると考えます。
modelsってそもそも?
Symbol sdk v3では例えば以下のようにトランザクションを生成します。
const tx = facade.transactionFactory.create({
type: 'transfer_transaction',
signerPublicKey: keypair.publicKey.toString(),
deadline:createDeadline(),
recipientAddress: 'TD4WXUXYAPPB5Y42VT6FHISG6T32I2IBUXIKKPQ',
message:[0,...(new TextEncoder('utf-8')).encode('GoodLuck!')],
mosaics: [
{ mosaicId: 0x3A8416DB2D53B6C8n, amount: 1000000n }
]
})
参考: Symbolの新SDKからSSS(Safely Sign Symbol) Extensionを利用してみる
詳細はぜひご自身で見ていただければ良いのですがざっくりここでのcreate()ではModelsのTransactionFactory.createByName()
(https://github.com/symbol/symbol/blob/d39c00b72791094fbbb194d01cfd2b2afb042470/sdk/javascript/src/symbol/models.js#L11429) にtypeを渡しています。
つまり
TransactionFactory.createByName('transfer_transaction')
で戻り値はTransferTransaction
クラス。さらに他の引数をそのクラスにセットしています。また、このTransferTransaction
はModels内のこちらです。
(https://github.com/symbol/symbol/blob/d39c00b72791094fbbb194d01cfd2b2afb042470/sdk/javascript/src/symbol/models.js#L10963)
また、このTransferTransaction
のコンストラクタでは以下のように例えばSignature
を初期化しており、そのSignature
はここに定義されています。
constructor() {
this._signature = new Signature();
this._signerPublicKey = new PublicKey();
this._version = TransferTransaction.TRANSACTION_VERSION;
this._network = NetworkType.MAINNET;
this._type = TransferTransaction.TRANSACTION_TYPE;
this._fee = new Amount();
this._deadline = new Timestamp();
this._recipientAddress = new UnresolvedAddress();
this._mosaics = [];
this._message = new Uint8Array();
this._verifiableEntityHeaderReserved_1 = 0; // reserved field
this._entityBodyReserved_1 = 0; // reserved field
this._transferTransactionBodyReserved_1 = 0; // reserved field
this._transferTransactionBodyReserved_2 = 0; // reserved field
}
このようにトランザクションを生成するためのモデルがすべて記載されている。それがModelsになります。
generator の流れを確認する
新たに作成する前に簡単にどのようなことがgeneratorで行われているのか?また自分で別言語用のgeneratorを作成する場合に何が必要なのか?を確認していきましょう。
main.py
Parserのmain.pyのmain関数を見てみる(なお、Pythonの知識はほとんど無いのであしからず)
実行されるのはここです※実行するときのオプションで --output
を選択するから
if args.output:
if args.generator:
generator_class = _load_generator_class(args.generator)
generator_class.generate(processor.type_descriptors, args.output)
generator_class.generate
に2つの引数を渡す
松本さんとGimreさんのやりとりを見る限り引数は
1.Schema
2.出力するディレクトリ
で、ここで言うgenerator_classは言語ごとGeneratorのGenerator.pyになります
https://github.com/symbol/symbol/blob/dev/sdk/javascript/generator/Generator.py
Generator.py
class Generator:
@staticmethod
def generate(ast_models, output):
print(f'python catbuffer generator called with output: {output}')
generate_files(ast_models, Path(output))
先程のmain.pyで実行されるgenerate()ですね。
ここでは受け取った引数をそのまま渡してる程度なのでgenerate_files()を見てみます
def generate_files(ast_models, output_directory: Path):
factory_map = build_factory_map(ast_models)
extend_models(ast_models, create_printer)
output_directory.mkdir(exist_ok=True)
with open(output_directory / 'models.js', 'w', encoding='utf8', newline='\n') as output_file:
output_file.write(
'''/* eslint-disable max-len, object-property-newline, no-underscore-dangle, no-use-before-define */
const { BaseValue } = require('../BaseValue');
const { ByteArray } = require('../ByteArray');
const { BufferView } = require('../utils/BufferView');
const { Writer } = require('../utils/Writer');
const arrayHelpers = require('../utils/arrayHelpers');
const converter = require('../utils/converter');
'''
)
for ast_model in ast_models:
generator = TypeFormatter(to_type_formatter_instance(ast_model))
output_file.write(str(generator))
output_file.write('\n')
factories = []
factory_names = []
for ast_model in ast_models:
if DisplayType.STRUCT == ast_model.display_type and ast_model.is_abstract:
factory_formatter = FactoryFormatter(factory_map, ast_model)
factory_generator = FactoryClassFormatter(factory_formatter)
factory_names.append(factory_formatter.typename)
factories.append(str(factory_generator))
output_file.write('\n'.join(factories))
generate_module_exports(output_file, list(map(lambda ast_model: ast_model.name, ast_models)) + factory_names)
出力ディレクトリを作成して、model.jsというファイルに書き込んでいます。
javascriptの場合は最初に以下を記述しています。
const { BaseValue } = require('../BaseValue');
生成後のmodel.jsを見ると冒頭はそこが記述されていますね。ここは各言語でほかファイルのインポートなどに活用する箇所です。
次はここ
for ast_model in ast_models:
generator = TypeFormatter(to_type_formatter_instance(ast_model))
output_file.write(str(generator))
output_file.write('\n')
factories = []
factory_names = []
for ast_model in ast_models:
if DisplayType.STRUCT == ast_model.display_type and ast_model.is_abstract:
factory_formatter = FactoryFormatter(factory_map, ast_model)
factory_generator = FactoryClassFormatter(factory_formatter)
factory_names.append(factory_formatter.typename)
factories.append(str(factory_generator))
2つのfor文があります。
先に2つ目のfor文を説明すると、ここでは先程使用したTransactionFactory
やEmbeddedTransactionFactory
が書き出される箇所です。EmbeddedTransactionFactory
についてはSymbolではアグリゲートトランザクションが使えるので、アグリゲート内でのInnerTransactionを生成するためのものです。(Innner=Embedded(埋め込み))
1つ目が、そのFactoryでの戻り値である各トランザクションクラスやそのトランザクションクラスで使用されるフィールドクラスなどを生成するための箇所です。
それではここからそれぞれ解剖していきます。
TypeFormatter
1つ目のfor文では各ast_modelをto_type_formatter_instance()に渡し、さらにその戻り値をTypeFormatter()の引数としています。
def to_type_formatter_instance(ast_model):
type_formatter_class = {
DisplayType.STRUCT: StructFormatter,
DisplayType.ENUM: EnumTypeFormatter,
DisplayType.BYTE_ARRAY: PodTypeFormatter,
DisplayType.INTEGER: PodTypeFormatter
}[ast_model.display_type]
return type_formatter_class(ast_model)
ここではast_modelがどのタイプなのか判別し、それに基づいてどのフォーマッターを使用するか決定します。フォーマッターの種類は3つです。
StructTypeFormatter = 各種TransactionやMosaicなどのようにサイズ以外のフィールドを持つものに使用する
EnumTypeFormatter = Enum(列挙)型に使用する(例TransactionType)
PodTypeFormatter = サイズ以外のフィールドを持たない物に使用する(例Amount)
この3つのフォーマッターは下層フォーマッターと自分の中では考えており、それに対して上層フォーマッターがTypeFormatterとFactoryFormatterと考えています。これはあくまでも概念的な話で理解しておくとはかどります。(他にフォーマッターと名のつく物にAbstructFromatterがありますが、これは下層フォーマッターの基底クラスでほとんど触らない)
TypeFormatterを見ていきます。
先程のGenerator.pyの1つ目のfor文ではoutput_file.write(str(generator))
のようにstrメソッドの引数にTypeFormatterを渡します。このstrはTypeFormatterの基底クラスであるClassFormatter(同一ファイル内)の
def __str__(self):
return self.generate_output()
で、generate_output()
が
def generate_output(self):
output = self.generate_class()
return output
つまりgenerate_class()
ですね。
def generate_class(self):
output = self.generate_class_header()
# additional newline between fields for js linter
fields = self.provider.get_fields()
fields_output = indent('\n'.join(fields))
if fields_output:
output += fields_output + '\n'
methods = self.generate_methods()
output += '\n'.join(map(indent, methods))
output += '}\n' # class_footer
return output
ここでは最終的に戻り値でoutputを返すので、変数outputにどんどん追加していきます。
まずは、
output = self.generate_class_header()
def generate_class_header(self):
base_class = self.provider.get_base_class()
base_class = f' extends {base_class}' if base_class else ''
header = f'class {self.provider.typename}{base_class} {{\n'
comment = ''
return header + indent(comment)
ここでとても重要なのがproviderとは下層フォーマッターだと思えばOKです。このproviderは今後ずっと出てくるので覚えておきましょう。
provider とはつまり下層フォーマッターのことである
例えば、AmountだとするとフォーマッターはPodTypeFormatterなのでget_base_class()とは
def get_base_class(self):
return 'ByteArray' if self._is_array else 'BaseValue'
amountは_is_arrayがfalseなので'BaseValue'となりAmountの基底クラスはBaseValueとなります(BaseValueやByteArrayは言語に沿って要作成)
header = f'class {self.provider.typename}{base_class} {{\n'
ここがクラスの宣言箇所になります。例えばjsでAmountなら
class Amount extends BaseValue {
がoutputに代入されます。
ではgenerate_class()に戻って
fields = self.provider.get_fields()
先程と同じようにAmountを例にすると
def get_fields(self):
return [f'static SIZE = {self.pod.size};']
ここでのself.podとはast_modelのことで(初期化時にself.pod = ast_model)Amountであればサイズは8なので
static SIZE = 8;
ですね。
generate_class()に戻ると
fields_output = indent('\n'.join(fields))
です。PodTypeFormatterはシンプルでフィールドが1つしかないのでこれだけがoutputに追加代入されます。
ちなみにindentメソッドはよく使うので覚えておくと良いです。
さて、続いてgenerate_class()では
methods = self.generate_methods()
なのでTypeFormatterのgenerate_methods()を見てみましょう。
def generate_methods(self):
methods = []
ctor = self.generate_ctor()
if ctor:
methods.append(ctor)
getters_setters = self.generate_getters_setters()
methods.extend(getters_setters)
size_method = self.generate_size()
if size_method:
methods.append(size_method)
methods.append(self.generate_deserializer())
methods.append(self.generate_serializer())
representation = self.generate_representation()
if representation:
methods.append(representation)
return methods
各メソッドを配列で生成です。順に解説しますが
ザクッと
- コンストラクタ
- ゲッター、セッター
- サイズ
- デシリアライズ
- シリアライズ
- リプレゼンテーション(おそらくtoString()のみでさほど重要ではなさそう)
があります。
引き続きAmountを例に解説しますが、AmountのproviderであるPodTypeFormatterは一番シンプルでStructTypeなどの解説は今後追加できればと思います。
ctor = self.generate_ctor()
コンストラクタです。
def generate_ctor(self):
method_descriptor = self.provider.get_ctor_descriptor()
if not method_descriptor:
return None
method_descriptor.method_name = 'constructor'
return self.generate_method(method_descriptor)
method_descriptor = self.provider.get_ctor_descriptor()
なのでPodTypeFormatterのget_ctor_descriptor()を見てみます。
def get_ctor_descriptor(self):
variable_name = self.printer.name
body = f'super({self.typename}.SIZE, {variable_name});'
if self._is_array:
arguments = [f'{variable_name} = {self.printer.get_default_value()}']
else:
arguments = [f'{variable_name} = {self.printer.get_default_value()}']
return MethodDescriptor(body=body, arguments=arguments)
variable_name = self.printer.name
はい、初めてprinterなるものが登場しました。
printerというものは各フィールドに記述するコードなどを制御するためのものでprinters.py
というファイルがgenerator内にあります。これも結構触ります。(ちょっとうまく説明できない
ここでのprinter.nameについてはprinterの初期化時に以下のように言語ごとにキャメルケースやスネークケースなど活用箇所が違うので調整しています。
self.name = fix_name(lang_field_name(name or underline_name(self.descriptor.name)))
body = f'super({self.typename}.SIZE, {variable_name});'
javascriptでの基底クラスへの引き渡しですね。このあたり言語ごとに変わります。
amountであれば
super(Amount.SIZE, amount);
となります。
arguments = [f'{variable_name} = {self.printer.get_default_value()}']
ここはコンストラクタの引数です。amountのprinterはIntPrinterなのでget_default_value()は
def get_default_value(self):
return '0n' if 8 == self.descriptor.size else '0'
amount = 0n
になります。
ここで重要なポイントは最後に
return MethodDescriptor(body=body, arguments=arguments)
とMethodDescriptorの引数にbodyとargumentsを渡しているところです。
全てのメソッドはこのMethodDescriptorを介しているので覚えておくと良いです。とはいってもこのクラス自体にメソッドなどはなくフィールドがあるだけのものです。
https://github.com/symbol/symbol/blob/d39c00b72791094fbbb194d01cfd2b2afb042470/sdk/javascript/generator/AbstractTypeFormatter.py#L4
引数のbodyが後ほど関数内で処理されるもの、argumentsがその関数の引数です。
TypeFormatterのctorに戻ります。
method_descriptor.method_name = 'constructor'
return self.generate_method(method_descriptor)
method_nameが'constructor'でTypeFormatterのgenerate_methodの引数をmethod_descriptorとします。
ここはあまり詳しく解説しませんが、
method_descriptor.method_name = 'constructor'
method_descriptor.arguments = 'amount = 0n'
method_descriptor.body = 'super(Amount.SIZE, amount);'
であることは覚えておいてください。
TypeFormatterのgenerate_methods()に戻って、
methods.append(ctor)
とmethods配列に追加します。
この後、
self.generate_getters_setters()
self.generate_size()
self.generate_deserializer()
self.generate_serializer()
と、進みますが一旦ここまでにして先程のコンストラクタだけを出力すると
constructor(amount = 0n) {
super(Amount.SIZE, amount);
}
となります。メソッド名、引数、Bodyの関係性が分かりましたか?
このあと多少複雑にはなっていきますが、やっていることは基本的にこれの繰り返しで、ひとつひとつそのメソッドがどこで何が行われているのかを理解しながら進めていきます。
ひとまず、今日のところはここまで。今後はAmountを例に他のメソッドの解説。また他のフォーマッターの解説も時間をかけて行いたいと思います。
一旦ここで終了
ここから先はメモなので読まなくてよいです。いつか清書する用
StructFormatter
このあとすべてのフォーマッターを解説しますが、いずれのフォーマッターもやりたいことは
str関数では省略すると
generate_output
generate_class
generate_class_header
generate_methods
generate_ctor
みたいな感じでSchemaに定義されている内容次第で必要な関数を実行し出力している
さて、Generatorでやってることはなんとなく理解できたのでここでちょっとschemaを覗く、とりあえず冒頭だけを見てみる
parserによるとall.catsをインプットする
import "block.cats"
import "transaction.cats"
import "entity.cats"
import "types.cats"
using Amount = uint64
using BlockDuration = uint64
using BlockFeeMultiplier = uint32
つまりこの3行が最初に読み込まれているので、ちょっとmodel.jsを見てみる
/* eslint-disable max-len, object-property-newline, no-underscore-dangle, no-use-before-define */
const { BaseValue } = require('../BaseValue');
const { ByteArray } = require('../ByteArray');
const { BufferView } = require('../utils/BufferView');
const { Writer } = require('../utils/Writer');
const arrayHelpers = require('../utils/arrayHelpers');
const converter = require('../utils/converter');
class Amount extends BaseValue {
static SIZE = 8;
constructor(amount = 0n) {
super(Amount.SIZE, amount);
}
static deserialize(payload) {
const byteArray = payload;
return new Amount(converter.bytesToInt(byteArray, 8, false));
}
serialize() {
return converter.intToBytes(this.value, 8, false);
}
}
class BlockDuration extends BaseValue {
static SIZE = 8;
constructor(blockDuration = 0n) {
super(BlockDuration.SIZE, blockDuration);
}
static deserialize(payload) {
const byteArray = payload;
return new BlockDuration(converter.bytesToInt(byteArray, 8, false));
}
serialize() {
return converter.intToBytes(this.value, 8, false);
}
}
class BlockFeeMultiplier extends BaseValue {
static SIZE = 4;
constructor(blockFeeMultiplier = 0) {
super(BlockFeeMultiplier.SIZE, blockFeeMultiplier);
}
static deserialize(payload) {
const byteArray = payload;
return new BlockFeeMultiplier(converter.bytesToInt(byteArray, 4, false));
}
serialize() {
return converter.intToBytes(this.value, 4, false);
}
}
冒頭の
const { BaseValue } = require('../BaseValue');
はgenerate_filesで一番最初にやったあたり
次に
using Amount = uint64
この一行が
class Amount extends BaseValue {
static SIZE = 8;
constructor(amount = 0n) {
super(Amount.SIZE, amount);
}
static deserialize(payload) {
const byteArray = payload;
return new Amount(converter.bytesToInt(byteArray, 8, false));
}
serialize() {
return converter.intToBytes(this.value, 8, false);
}
}
になる
サイズ、コンストラクター、デシリアライズ、シリアライズだけのシンプルなクラス。
おそらくusing Amount = uint64
だとto_type_formatter_instanceでPodTypeFormatterを返すのでproviderはPodTypeFormatterになる。
https://github.com/symbol/symbol/blob/dev/sdk/javascript/generator/PodTypeFormatter.py
TypeFormatterを見直し、順に解読するとこのへんを実行している
self.provider.get_fields()
self.provider.get_ctor_descriptor()
self.generate_deserializer()
self.generate_serializer()
つながった。
なので、このへんの関数の出力を言語ごとに調整していけば良いと思われる。
これはとてもシンプルな構造だからわかりやすいけどSchemaの構造を読み解けば他も解読できそう
※ここからマジでさらにメモ(いつか清書したい
適当にディレクトリ作ってターミナルで以下実行(Mac)
git clone https://github.com/symbol/symbol.git
僕の場合はC#のSDKを作りたいので sdk/javascript
を複製してsdk/csharp
とする
symbol/sdk/csharp/scripts/run_catbuffer_generator.sh
と symbol/sdk/csharp/generator/Generator.py
を少しだけ書き換える
- --output "${git_root}/sdk/javascript/src/$2" \
+ --output "${git_root}/sdk/csharp/src/$2" \
- rm -rf "./src/${name}/models.js"
+ rm -rf "./src/${name}/models.cs"
- diff "./src/${name}/models.js" "./src/${name}2/models.js"
- rm -rf "./src/${name}2/models.js"
+ diff "./src/${name}/models.cs" "./src/${name}2/models.cs"
+ rm -rf "./src/${name}2/models.cs"
- with open(output_directory / 'models.js', 'w', encoding='utf8', newline='\n') as output_file:
+ with open(output_directory / 'models.cs', 'w', encoding='utf8', newline='\n') as output_file:
要は出力先をCSに変えるのとファイルの拡張子をCSにした(dry_runとか今は意味分かってないけど変えておいて良いだろう)
んで、シェルを実行してModelsを生成するんだけどわかりやすいようにnemとsymbolの中のmodels.jsを削除しておくと良い
$ cd symbol/sdk/csharp
$ ./scripts/run_catbuffer_generator.sh
csharp/sdk/src/nem/models.cs
csharp/sdk/src/symbol/models.cs
が作成されました。
もちろん中身はJSなのでここからいじっていこう。