サービスを実装する
ワークフローの入口は、標準の proto3 のサービスとして書けます。一回の実行が何を受け取って始まり、何を返して終わるか、どんな名前で失敗しうるか、途中で何を受け取るかを、サービスとして書きます。.flow にはそのサービスを実装すると書き、検査は、タスクを呼び先の API の記述と照らし合わせるのと同じように、ワークフローをサービスと照らし合わせます。.proto は普通の .proto のままなので、protoc や buf でそのまま扱え、ほかの言語のクライアントも、型をこの .proto から作れます。dandori の印は、dandori 専用のオプションで付けます。
dandori のオプション
オプションは、リポジトリの proto/dandori/v1/options.proto にあります。このファイルを、自分の proto のルートに dandori/v1/options.proto としてコピーし、サービスを書いたファイルから import してください。dandori 自身は、Google の well-known types と同じく、コピーがなくてもこのファイルを知っています。コピーが要るのは protoc と buf です。このファイルは buf の標準の lint を通ります。
サービスには、それを実装するワークフローの名前を書きます。メソッドには、実行にどう届くかを、次の四つのオプションのどれか一つで書きます。
| オプション | メソッドがすること |
|---|---|
(dandori.v1.start) |
実行を始める。リクエストがワークフローの入力、レスポンスが出力になり、fails には .flow の fail の名前を並べる |
(dandori.v1.event) |
event のタスクが待つ値を、実行に送る。リクエストがその値になる |
(dandori.v1.answer) |
callback のタスクに応答する。リクエストが応答になる |
(dandori.v1.status) |
実行がいまどこにいるかを聞く。レスポンスは dandori.v1.Status か、同じフィールドを持つメッセージ |
引当と発送の例は、次のサービスを実装しています (fulfillment.proto)。
import "dandori/v1/options.proto";
service FulfillmentService {
option (dandori.v1.workflow) = {name: "fulfillment", version: 1};
rpc Fulfill(FulfillRequest) returns (FulfillResponse) {
option (dandori.v1.start) = {fails: ["OutOfStock", "DeliveryFailed", "PackingLate"]};
}
rpc AnswerPacking(AnswerPackingRequest) returns (AnswerPackingResponse) {
option (dandori.v1.answer) = {task: "wait_for_packing"};
}
}
どの版も、最初の行にそう書いてあります。
workflow fulfillment v1 implements shop.FulfillmentService
use proto shop from "../specs/fulfillment.proto"
shop は use proto で .proto に付けた名前で、FulfillmentService はその中のサービスです。一つのワークフローが実装するサービスは一つです。この .proto を呼ぶタスクは無いので、url は要りません。
日本語の版は、JSON での名前(json_name)を日本語にした別の記述(fulfillment.ja.proto)のサービスを実装します。ワークフローや入力の名前が日本語の版と同じになるようにするためです。
何を照らし合わせるか
検査は、ワークフローとサービスの食い違いを、一つずつ別の診断で知らせます(E017)。
- サービスの
(dandori.v1.workflow)が、workflowの行と同じワークフローの名前とバージョンを書いていること。 - どのメソッドにも四つのオプションのどれか一つがあり、メッセージを一つ受け取って一つ返すこと(ストリームは使えない)。実行を始めるメソッドは一つだけ。
- 実行を始めるメソッドのリクエストのフィールドが、protobuf の JSON での名前(
json_name、無ければ lowerCamelCase)で入力と一つずつ対応し、入力がフィールドの値を読めること。型の比べ方は、タスクを API の記述と照らし合わせるときと同じです。64 ビットの整数はstringで読み、列挙はフィールドがとりうる値をすべて持ちます。 - そのレスポンスのフィールドが出力と一つずつ対応し、出力の値をフィールドに書けること。無いことがある出力(
T?)は、設定されているかどうかが分かるフィールドにしか書けません。 failsに、.flowが失敗するときの名前がすべて並び、それ以外の名前が無いこと。.protoにはエラーを書く場所が無いので、クライアントは失敗の名前をここから知ります。(dandori.v1.event)のメソッドにはeventのタスクの名前を、(dandori.v1.answer)のメソッドにはcallbackのタスクの名前を書き、そのタスクの結果の型でメソッドのリクエストを読めること。メソッドは何も返さないこと。一つのタスクの名前を書けるメソッドは一つだけ。(dandori.v1.status)のメソッドが何も受け取らず、dandori.v1.Statusと同じものを返すこと。- ファイルが
dandori/v1/options.protoを import していること(protoc と buf がそれを求めます)。
サービスとワークフローがずれてしまった下書き
(tests/fixtures/service.flow)を --lang ja で検査すると、たとえば次の診断が出ます。
エラー[E017]: tests/fixtures/service.flow:1:36: ワークフローは `PackingLate` で失敗することがありますが、`Fulfill` の `fails` にありません
1 | workflow fulfillment v1 implements shop.FulfillmentService
エラー[E017]: tests/fixtures/service.flow:1:36: `Fulfill` の `fails` に `Lost` がありますが、ワークフローがその名前で失敗することはありません
1 | workflow fulfillment v1 implements shop.FulfillmentService
型は名前ではなく形で比べます。入力を .proto のメッセージ(shop.Order)にすれば、そのまま合います。.flow のレコードでもかまいません。引当と発送の注文がそうで、金額は範囲の付いた money[JPY, incl_tax] ですが、.proto の側は単位を持たない int32 です。
サービスに、ワークフローが待つイベントとコールバックの全部を書く必要はありません。書くのは、そのサービスのクライアントが送るものだけです。たとえば、規則の日本語の状態を運ぶイベント(試験用のフローにあります)は、値が ASCII の名前である .proto の列挙には書けません。一方、クライアントが受け取るもの(出力、失敗の名前、実行がいまどこにいるか)は、いつも全部を書きます。
受け取るものと返すもの
値はどれも JSON で実行に届き、実行から返ります。形は、protobuf の JSON がそのメッセージに与える形です。protobuf の JSON では、optional の付かないフィールド(メッセージ型を除く)は、ゼロ値(空の文字列、0、false、列挙の最初の値、空のリストやマップ)のとき省かれます。そこで、生成したコードは、どのプラットフォームでも、受け取ったものを確かめる前に、.proto をもとにゼロ値を埋めます。埋めるのは、実行の入力と、サービスのメソッドが送ってくるイベントの値とコールバックの応答で、その中のメッセージや、リストの中のメッセージにも埋めます。
値が無くてもよいかは、ワークフローが決めます。設定されているかどうかが分かるフィールド(メッセージ型のフィールド、optional を付けたフィールド、oneof の一つ)を、T? でない入力で読んでもかまいません。そのフィールドの無いリクエストが来たら、実行はその場で Dandori.BadInput で失敗します。ただし json の入力は別で、設定されていない google.protobuf.Value は protobuf の JSON から省かれるので、無ければ null として読みます。
返すものは、ゼロ値も含めてすべて書きます。protobuf の JSON を読む側は、書いてあるゼロ値も読めます。サービスを実装するワークフローの実行は、出力が無いときも {} で終わります。protobuf は null からメッセージを読まないからです。
ほかの言語のクライアント
値は JSON で、Temporal では json/plain のペイロードになります。dandori が生成する TypeScript、Python、Go のクライアントは、そのように送ります。自分で書くクライアントは、メッセージを protobuf の JSON にして、それを JSON の値として渡します。Go なら json.RawMessage(protojson.Marshal(m))、Python なら json_format.MessageToDict(m) です。文字列のまま渡すと、ワークフローには文字列が届き、実行は Dandori.BadInput で失敗します。protobuf 独自のペイロード(json/protobuf)は読みません。Temporal の Go の SDK は、メッセージをそのまま渡すと、この形のペイロードにします。
Temporal では
メソッドは、クライアントがもともとしていることに対応します。ワークフローの型は <名前>_v<バージョン>(fulfillment_v1)で、イベントは Update の dandori.event、コールバックへの応答は Update の dandori.answer、実行がいまどこにいるかはクエリの dandori.status です。生成する client.ts、client.py、client.go には、ほかに次のものが入ります。
SERVICE(Go ではService):サービスの完全な名前- メッセージごとの型。名前は
.protoのメッセージの名前で、FulfillRequestはワークフローの入力、FulfillResponseは出力、イベントとコールバックのリクエストはそのタスクの結果の型です。 - メソッドごとの関数。名前はメソッドの名前から作り(
fulfill、answerPacking。Python ではanswer_packing、Go ではFulfill、AnswerPacking)、中でstart・send・answer・statusを呼びます。クライアントにもともとある関数と同じになるメソッド(Startならstart)には、関数を足しません。
ほかのプラットフォームでは
実行の始め方とコールバックへの応答は、どのプラットフォームでも同じです。Step Functions、Lambda durable functions、Argo Workflows、pydantic-graph でも、入力と応答は入口でゼロ値を埋めてから読みます。イベントは、いつもどおり Temporal だけのものです。実行がいまどこにいるかを聞くメソッドも Temporal だけで、ほかのプラットフォームには実行が答える問い合わせが無いので、そのメソッドを持つサービスはエラーになります(E050)。それらのプラットフォーム向けのサービスからは、このメソッドを外してください。
buf の lint
buf の標準の lint は、メソッドのリクエストとレスポンスにメソッドの名前を付け(GetStatusRequest、GetStatusResponse)、モジュール全体でほかのメソッドと共有しないことを求めます。そのため、検査はメソッドのメッセージを、名前ではなくフィールドで比べます。lint を通すには、dandori.v1.Status の三つのフィールドを、そのメソッド専用のレスポンスにコピーします。
message GetStatusResponse {
optional int32 at = 1;
map<string, google.protobuf.Value> cases = 2;
repeated string events = 3;
}
lint を気にしなければ、dandori.v1.Status をそのままレスポンスにしてかまいません。