kanna

kanna / mapper

kanna-mapper

v0.0.13 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-mapper は、ドメイン型と、自分では管理できないワイヤ型(protobuf などの通信用の型)の間を変換する関数を生成します。シグネチャは自分で宣言し、本体は kanna-mapper がフィールドごとに生成します。Go が自力で変換できない型には、リストに挙げた変換を呼び出します。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上が必要です。

インストール

go get -tool github.com/go-kanna/mapper/cmd/kanna-mapper

インポートするランタイムはありません。生成コードが呼び出すのは、自分の型、リストに挙げた関数、fmt と、値オブジェクトおよび enum のコンストラクタです。

使い方

必要な関数を、//kanna:mapper を付けたインターフェースのメソッドとして宣言し、Go が自力でできない変換を //kanna:converter のリストに挙げます:

package mapper

//go:generate go tool kanna-mapper

import (
	"uuid"

	"google.golang.org/protobuf/types/known/timestamppb"

	"example.com/app/gen/employeev1"
	"example.com/app/model"
)

//kanna:mapper
type _ interface {
	EmployeeToProto(model.Employee) *employeev1.Employee
	EmployeeFromProto(*employeev1.Employee) (model.Employee, error)
}

//kanna:converter
var _ = []any{
	uuid.UUID.String,
	uuid.Parse,
	timestamppb.New,
	(*timestamppb.Timestamp).AsTime,
}

両側の型が次のようになっているとします:

// package model: yours
type Employee struct {
	ID       uuid.UUID
	FullName string `map:"Name"`
	HiredAt  time.Time
	Address  Address
}

// package employeev1: generated by protoc-gen-go
type Employee struct {
	Id      string
	Name    string
	HiredAt *timestamppb.Timestamp
	Address *Address
	// ...
}

go generate ./... を実行すると、宣言と同じディレクトリに mapper_gen.go が書き出されます:

// Code generated by kanna-mapper. DO NOT EDIT.

// EmployeeToProto maps model.Employee to *employeev1.Employee.
func EmployeeToProto(src model.Employee) *employeev1.Employee {
	return &employeev1.Employee{
		Id:      src.ID.String(),
		Name:    src.FullName,
		HiredAt: timestamppb.New(src.HiredAt),
		Address: addressToEmployeev1(src.Address),
	}
}

// EmployeeFromProto maps *employeev1.Employee to model.Employee.
func EmployeeFromProto(src *employeev1.Employee) (model.Employee, error) {
	if src == nil {
		return model.Employee{}, nil
	}
	v, err := uuid.Parse(src.GetId())
	if err != nil {
		return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err)
	}
	return model.Employee{
		ID:       v,
		FullName: src.GetName(),
		HiredAt:  src.GetHiredAt().AsTime(),
		Address:  addressToModel(src.GetAddress()),
	}, nil
}

// addressToEmployeev1 maps model.Address to *employeev1.Address.
func addressToEmployeev1(src model.Address) *employeev1.Address {
	// ...
}

宣言は普通の Go のコードです。コンパイラとエディタがその中のすべての型と関数をチェックし、宣言で使うインポートは、ブランクインポートを書かなくても残ります。インターフェースが実装されることは決してないので、_ という名前にできます。どんな名前でも構いません。(uuid は Go 1.27 で追加された標準ライブラリのものです。github.com/google/uuid も同じようにリストに挙げられます。)

マッピングを生成できない場合は、最初にそれに当たったリクエストを止めるのではなく、生成を止めます。ワイヤ型にドメインにはない Revision があり、ドメインの Status が独自の enum で、EmployeeFromProto がエラーなしで宣言されていた場合は、次のようになります:

$ go generate ./...
./mapper.go:15:2: error: EmployeeToProto: no source in model.Employee for employeev1.Employee.Revision
  add map:"Revision" to the field of model.Employee that fills it, or leave it zero with //kanna:map Revision=-
./mapper.go:15:2: error: EmployeeToProto: cannot map model.Employee.Status (model.Status) to employeev1.Employee.Status (employeev1.Status)
  add a func(model.Status) employeev1.Status to a //kanna:converter list
./mapper.go:16:2: error: EmployeeFromProto has to return an error: ID is converted by uuid.Parse, which can fail
  declare it as EmployeeFromProto(*employeev1.Employee) (model.Employee, error)

関数の宣言

//kanna:mapper を付けたインターフェースの各メソッドは、kanna-mapper が生成する関数で、名前とシグネチャはそこに書いたとおりになります:

  • パラメータは変換元の 1 つで、戻り値は変換先か (destination, error)
  • 変換元と変換先は構造体型かそのポインタで、ジェネリックではない
  • パラメータに名前があればその名前を、なければ src を使う
  • メソッドの doc コメントが、//kanna: の行を除いて関数の doc コメントになる。doc コメントがなければ、関数には上の例のような文が付く
  • 非公開の名前のメソッドからは非公開の関数が生成される。パッケージがすでに宣言している名前はエラーになる

1 つのパッケージに、このようなインターフェースを複数置けます。どれも、そのパッケージの 1 つの mapper_gen.go に生成されます。別のインターフェースを埋め込んだインターフェースはサポートしていません。

宣言した関数は、初回の実行で生成される前から呼び出していても、宣言の変更によってこれから変わる形で呼び出していても構いません。kanna-mapper は読み込んだパッケージを、これから書き出す内容を組み込んだ状態でもう一度型チェックし、それでコンパイルが通らない間は何も書き出しません。関数がないために隠れていた間違い、たとえば関数の戻り値にないフィールドの参照も、このとき報告されます。

関数がエラーを返すかどうかは、宣言で決まります。必要な変換が失敗しうるのにエラーのない宣言は、生成を止め、どのフィールドがエラーを必要としているかを示します。そのため、シグネチャが変わるのは自分で変えたときだけです。エラーの原因になるものがないのにエラーのある宣言は問題なく、nil を返します。API が後から失敗することを見込んでいる場合もあるからです。

上の Address のように、型の組を誰も宣言していない構造体フィールドは、kanna-mapper がその組のために生成する非公開の関数でマッピングされます。この関数は、同じ組が現れるすべての箇所で再利用されます。別の場所から呼び出したい場合や、//kanna:map でフィールドを調整したい場合は、その組を自分で宣言してください。このような関数の内部の問題は、その関数に至った宣言の位置に、パスとともに報告されます:

./mapper.go:15:2: error: EmployeeToProto: Address: no source in model.Address for employeev1.Address.Etag
  declare AddressToEmployeev1(model.Address) *employeev1.Address in the //kanna:mapper interface and add //kanna:map Etag=-

変換

//kanna:converter のリストには、Go が変換できないときに型を変換する関数を挙げます:

  • 任意のパッケージの関数を名前で指定する: uuid.Parse、timestamppb.New、または自分の関数
  • メソッドをメソッド式で指定する: uuid.UUID.String、(*timestamppb.Timestamp).AsTime

どれも変換元を受け取って変換先を返し、エラーも返せます: func(A) B または func(A) (B, error)。関数は関数として、メソッドは src.ID.String() のようにメソッドとして呼び出されます。関数リテラル、メソッド値、ジェネリック関数は拒否されます。名前を付けてください。

リストは、複合リテラルで初期化した []any 型の変数で、名前は _ でも構いません。パッケージ内のすべてのリストが、そのパッケージのすべての //kanna:mapper インターフェースに適用されます。リストの外のものが呼び出されることは決してありませんが、足りない変換に合う関数がパッケージにあれば、エラーで指摘されます。

パッケージのリストに同じ型の組のエントリが 2 つあると、使われるかどうかにかかわらず生成を止めます。これにより、uuid.Parse の隣に uuid.MustParse を追加しても、どちらが実行されるかが変わることはありません。パッケージで宣言した関数がすでにマッピングしている組のエントリも、同じく生成を止めます。

リストの共有

名前付きのリストは別のリストのエントリにでき、そのリストは名前付きのリストの内容をすべて持つことになります。アプリケーションのあらゆる場所で使う変換は、1 回だけ書きます:

package convert

//kanna:converter
var Defaults = []any{
	uuid.UUID.String,
	uuid.Parse,
	timestamppb.New,
	(*timestamppb.Timestamp).AsTime,
}

そして、1 つのサービスのハンドラのように、マッピングを行う各パッケージで使います:

package billing

//kanna:converter
var _ = []any{
	convert.Defaults,
	statusToProto,
}

リストを指すエントリは、//kanna:converter を付けたリストを指していなければなりません。共有リストが持ち込む関数は、それを使うパッケージから呼び出せる必要があるので、非公開の関数はそのパッケージでエラーになります。同じ関数が 2 回入ってきても 1 回と数えます。convert のようにリストしか持たないパッケージには、//go:generate の行は不要です。そのリストを指すパッケージが読み取るからです。

異なる 2 つの関数が同じ組を変換する場合は、埋め込みで昇格するフィールドと同じく、近い方が優先されます。まずパッケージ自身のリストと宣言した関数、次にそれらが指すリスト、さらにそのリストが指すリストの順です。同じ距離に 2 つあるとエラーになります。(*timestamppb.Timestamp).AsTime では Unix エポックになる、値のないタイムスタンプを、あるパッケージではゼロ値の time.Time として読みたい場合は、そのパッケージのリストに自分の関数を挙げます。その関数が、convert.Defaults が持ち込む AsTime に取って代わります。

フィールドの対応付け

変換先の各フィールドには、最初に一致したルールが適用されます。

ルール 例
変換先フィールド自身のタグ、またはその //kanna:map map:"EmployeeName" タグを付けた変換先は EmployeeName を読む
変換先の名前をタグに持つ変換元フィールド map:"Name" タグを付けた変換元が Name を埋める
同じ名前 Name → Name
大文字小文字を問わず同じ名前 ID → Id
昇格したフィールド 埋め込み構造体のフィールドで、名前が完全に一致するもの

変換元のない変換先フィールドはエラーになり、黙ってゼロ値にはなりません。map:"-" タグを付けたフィールドは、どちらの方向でもマッピングの対象になりません。昇格したフィールドのタグは引き続き適用されます。埋め込み構造体で除外または改名されたフィールドは、元の名前では一致しません。埋め込みポインタを通じて昇格したフィールド(ポインタが nil だと読み取りで panic するため)と、map:"-" タグを付けた埋め込みフィールドを通じて昇格したフィールドも、同じく一致しません。

複数の型にマッピングされる構造体には、そのうち一部の型にしかないフィールドを指すタグが付いていることがあります。そのため、変換先のどのフィールドも指していないタグは、それだけではエラーになりません。変換先フィールドに変換元がなく、変換元フィールドのタグがそのフィールドの名前に近い場合は、エラーでそのタグを指摘します。

protoc-gen-go が生成するようなゲッターを変換元が宣言している場合は、src.GetName() のようにゲッターを通じて読むので、nil のメッセージは空として読まれます。nil ポインタをゼロ値に変えるゲッターは使わず、フィールド自体を読みます。ただし、ゲッターの戻り値がすでに変換先の型である場合は除きます。これにより、未設定のフィールドは、空の値から変換されるのではなく、ゼロのままになります。

タグを付けられないフィールド

protoc-gen-go が生成する型には、自分でタグを付けることができません。メソッドの上の //kanna:map の行は、その関数に限って、変換先フィールドが何を読むかを指定します:

//kanna:mapper
type _ interface {
	// EmployeeToProto leaves Revision and UpdateTime to the server.
	//
	//kanna:map Revision=- UpdateTime=-
	EmployeeToProto(model.Employee) *employeev1.Employee
}

Field=- は変換先フィールドをゼロのままにします。Field=Source は、変換先のタグと同じように、読み取る変換元フィールドを指定します。変換先のどのフィールドも指していないエントリはエラーになり、おそらく指そうとしていたフィールドが示されます。エントリは、その関数に限り、そのフィールドのタグに取って代わります。そのため、複数の型から埋められる構造体は、型ごとにそれぞれの名前で読み取れます。Field だけの場合は、フィールドの埋め方は何も変わりませんが、strict マッピングで求められるとおり、そのフィールドを名指しします。

strict マッピング

同じ名前の変換元を持つ変換先フィールドは、何も言わずに埋められます。サービスの内部ではそれこそが狙いですが、変換先がサービスの外の世界に見せるものである場合は違います。ドメインとレスポンス型に同じ名前のフィールドを追加すると、レビューされないまま外に出てしまいます。そのような関数のインターフェースに //kanna:mapper strict を付けると、変換先の各フィールドを、関数ごとに、//kanna:map か、そのフィールドを指すタグ(変換先のものでも変換元のものでも)で名指ししなければならなくなります:

//kanna:mapper strict
type _ interface {
	//kanna:map ID Name Email=ContactEmail Internal=-
	UserToPublic(model.User) api.User
}

名前だけで一致したフィールドは生成を止めます。strict な関数が、生成された関数を呼ぶことになる構造体の組も同様です。どのマッピングのために生成された関数でも同じです。その関数はフィールドを名指しなしで埋めるからです。その組をインターフェースで宣言し、フィールドを名指ししてください:

./mapper.go:15:2: error: UserToPublic: api.User.RecoveryToken is not named; a strict mapping fills only the fields //kanna:map or a tag names
  add RecoveryToken to the //kanna:map of UserToPublic, or RecoveryToken=- to leave it zero

値の変換

各フィールドには、最初に当てはまったケースが適用されます。

ケース 結果
型が同一 そのまま代入する。スライスとマップはコピーする
その組の宣言された関数、またはリストに挙げた変換がある その関数を呼び出す
変換元がポインタ デリファレンスする。nil なら変換先はゼロのまま
変換先がポインタ 変換した値のアドレス
変換元が kanna-value の値オブジェクト アクセサが返す値を変換する
変換先が kanna-value の値オブジェクト 変換した値でコンストラクタを呼び出す
kanna-value の enum と protobuf の enum 定数を対応付ける非公開の関数
変換先が kanna-value の enum 変換した文字列でコンストラクタを呼び出す
両方がスライス 要素ごとに変換する。nil は nil のまま
両方が同じキー型のマップ 値ごとに変換する。nil は nil のまま
情報が失われない Go の変換 dst(v)。ただし、両方の型が type で宣言されている場合を除く
両方が公開フィールドを持つ構造体、またはそのポインタ その組のために生成された非公開の関数

「情報が失われない」は厳密な意味です。int32 → int64 は変換しますが、int64 → int32 は変換しません。int → int32(int が 64 ビットのプラットフォームがあります)、int → uint(符号)、int64 → float64(精度)、数値 → string も変換しません。

type で宣言された 2 つの型は、片方をもう片方として書けるとしても、自動で相互に変換されることは決してありません。protobuf の enum とドメインの enum はどちらも int32 かもしれませんが、ワイヤ上の 0 は STATUS_UNSPECIFIED で、ドメインではおそらく実在のステータスです。数値のまま変換すると、意味が黙って変わってしまいます。両者の間の関数をリストに挙げるか、ドメイン側を、名前でマッピングされる kanna-value の enum にしてください。宣言された型と事前宣言された型は、それでも変換されます: model.Email → string は string(src.Email) です。

変換先もポインタなら、nil の変換元は常に nil のままです。これは、*time.Time を埋める (*timestamppb.Timestamp).AsTime のように、変換元のポインタそのものを受け取る変換にも当てはまります。この変換は、変換元が設定されているときにだけ呼び出されます。time.Time を埋める場合は、変換元の値が何であれ呼び出され、nil がどうなるかはその変換が決めます。AsTime は nil を Unix エポックにします。別の結果にしたい場合は、自分の関数をリストに挙げてください。

失敗した変換のエラーは、map model.Employee.ID: invalid uuid のように、対象の変換先フィールドを示します。

コピー

両側で同じ型のスライスとマップは、slices.Clone または maps.Clone でコピーされるので、変換先の中身を変更しても変換元はそのままで、その逆も同じです。nil は nil のままです。その中身まではコピーされず、同じ型の構造体の中のスライスもコピーされません。コピーされるのは、マッピングするフィールドだけです。

ファイルのバイト列のように、コピーすると二重に持つことになるフィールドをそのまま渡したい場合は、コピーの代わりに共有します。protoc-gen-go が生成する型のようにタグを付けられない型では、関数の //kanna:share の行に変換先フィールドの名前を書きます。自分の型のフィールドに map:",share" または map:"Name,share" のタグを付けると、そのフィールドをマッピングするすべての関数に適用されます:

//kanna:mapper
type _ interface {
	//kanna:share Content
	DocumentToProto(model.Document) *docv1.Document
}
func DocumentToProto(src model.Document) *docv1.Document {
	return &docv1.Document{
		Tags:    slices.Clone(src.Tags),
		Content: src.Content,
	}
}

変換先のどのフィールドも指していない //kanna:share と、スライスでもマップでもないフィールドを指す //kanna:share は、生成を止めます。

値オブジェクト

kanna-value が生成する値オブジェクトは、アクセサで読み取り、コンストラクタで作ります。そのため、ワイヤやデータベースの行から届く値も、他の値と同じようにルールを通ります。保持している値は、上の変換のルールに従って変換されます。timestamppb.New と (*timestamppb.Timestamp).AsTime をリストに挙げると、time.Time を保持する *model.CompletedAt は *timestamppb.Timestamp と相互にマッピングされます:

// TodoToProto maps model.Todo to *todov1.Todo.
func TodoToProto(src model.Todo) *todov1.Todo {
	var v *timestamppb.Timestamp
	if v2 := src.CompletedAt; v2 != nil {
		v = timestamppb.New((*v2).Time())
	}
	return &todov1.Todo{
		CompletedAt: v,
	}
}

// TodoFromProto maps *todov1.Todo to model.Todo.
func TodoFromProto(src *todov1.Todo) (model.Todo, error) {
	if src == nil {
		return model.Todo{}, nil
	}
	var v *model.CompletedAt
	if v2 := src.GetCompletedAt(); v2 != nil {
		v3, err := model.NewCompletedAt(v2.AsTime())
		if err != nil {
			return model.Todo{}, fmt.Errorf("map model.Todo.CompletedAt: %w", err)
		}
		v = &v3
	}
	return model.Todo{
		CompletedAt: v,
	}, nil
}

ルールは値を拒否できるので、値オブジェクトを作る関数はエラーを返さなければなりません。ルールのエラーは他のエラーと同じようにラップされます。変換元のゼロは、他の値と同じようにチェックされます。値が渡されなかったことを表す、値オブジェクトのゼロ値とは見なされません。省略可能なフィールドはポインタにマッピングしてください。ポインタなら nil のままになります。型が変わらない値は、チェックし直さずコピーされます。model.CompletedAt から *model.CompletedAt への場合も含みます。

値オブジェクトは、//kanna:value を読むのではなく、kanna-value が与える形で識別します。kanna-mapper には、宣言しているパッケージの型しか見えないことがあるからです。その形とは、非公開フィールドを 1 つ持つ構造体、フィールドの型にちなんだ名前でそのフィールドを返すメソッド(time.Time なら Time、string なら String)、そして隣にある NewX(T) (X, error) です。その組の関数がリストにあれば、他の型と同じく、代わりにその関数が呼び出されます。別のパッケージの非公開の値オブジェクトは、コンストラクタも非公開なのでここでは作れず、そのフィールドはマッピングできないものとして報告されます。別のパッケージを通してしか見えないパッケージの値オブジェクトも同様です。経由するパッケージのエクスポートデータには、型はあってもコンストラクタがないからです。deep.NewLevel のようにコンストラクタをリストに挙げてください。それによって、そのパッケージが取り込まれます。

Enum

kanna-value の enum(定数の値だけを取る string 型)は、定数の名前によって protobuf の enum と相互にマッピングされます。比較するのは、Go の定数では型名より後の部分(OrderStatusPlaced の Placed)、protobuf の値では enum 名より後の部分(ORDER_STATUS_PLACED の PLACED)で、大文字小文字とアンダースコアの違いは無視します。方向ごとに関数が 1 つ生成されます:

// orderStatusToOrderv1 maps model.OrderStatus to orderv1.OrderStatus.
func orderStatusToOrderv1(src model.OrderStatus) orderv1.OrderStatus {
	switch src {
	case model.OrderStatusPlaced:
		return orderv1.OrderStatus_ORDER_STATUS_PLACED
	case model.OrderStatusPaid:
		return orderv1.OrderStatus_ORDER_STATUS_PAID
	}
	return orderv1.OrderStatus_ORDER_STATUS_UNSPECIFIED
}

// orderStatusToModel maps orderv1.OrderStatus to model.OrderStatus.
func orderStatusToModel(src orderv1.OrderStatus) (model.OrderStatus, error) {
	switch src {
	case orderv1.OrderStatus_ORDER_STATUS_UNSPECIFIED:
		return "", nil
	case orderv1.OrderStatus_ORDER_STATUS_PLACED:
		return model.OrderStatusPlaced, nil
	case orderv1.OrderStatus_ORDER_STATUS_PAID:
		return model.OrderStatusPaid, nil
	}
	return "", fmt.Errorf("model.OrderStatus has no value for %v", src)
}

protobuf の 0 とドメインの空文字列はどちらも値がないことを表し、相互にマッピングされます。ただし、0 の値に名前で一致する定数がある場合は除きます。protobuf にあってドメインにない値や、誰も宣言していない数値は、ドメインに戻ってくる方向でエラーになります。そのため、protobuf から enum をマッピングする関数はエラーを返さなければなりません。一方、ドメインにあって protobuf にない定数は、生成を止めます:

./mapper.go:15:2: error: OrderToProto: Status: model.OrderStatusRefunded has no counterpart in orderv1.OrderStatus
  add ORDER_STATUS_REFUNDED to it, or list a func(model.OrderStatus) orderv1.OrderStatus in a //kanna:converter list

メッセージが string として持つフィールドのような普通の文字列は、コンストラクタ NewOrderStatus を通じて kanna-value の enum になります。このコンストラクタは、どの定数でもない文字列を、空文字列も含めて拒否します。省略可能なフィールドはポインタにマッピングしてください。逆方向では、enum はすでに文字列なので、そのまま変換されます。

protobuf の enum は protoc-gen-go が与える形(Enum メソッドを持つ int32)で、kanna-value の enum は定数が隣にある string 型という形で識別します。kanna-mapper には、宣言しているパッケージの型しか見えないことがあるからです。その組の関数がリストにあれば、他の型と同じく、代わりにその関数が呼び出されます。

Protobuf の oneof

oneof は、インターフェース型のフィールドとして届き、その実装はフィールドを 1 つだけ持つラッパー構造体です。protoc-gen-go がそのように生成するからです。ドメイン側では、設定されるのが多くても 1 つのポインタフィールドの集まりです:

type Change struct { // domain
	Version    int64
	Membership *Membership
	Category   *Category
}

type Change struct { // wire, as protoc-gen-go wrote it
	Version int64
	Kind    isChange_Kind // *Change_Membership | *Change_Category
}

各アームは、そのラッパーのフィールドの名前により、上の対応付けのルールでドメインのフィールドに対応付けられ、そのフィールドと同じように変換されます。ドメインに戻ってくる方向では、oneof に対する型 switch が、そこにあるアームのフィールドを埋めます。ドメインから出ていく方向では、oneof の宣言順で最初に設定されているフィールドがアームを埋め、どれも設定されていなければ oneof は nil のままです:

if v := src.Membership; v != nil {
	dst.Kind = &syncv1.Change_Membership{Membership: membershipToSyncv1(*v)}
} else if v2 := src.Category; v2 != nil {
	dst.Kind = &syncv1.Change_Category{Category: categoryToSyncv1(*v2)}
}

ドメインのフィールドは、ポインタ、スライス、マップのいずれかでなければなりません。アームが設定されているかどうかを、それで表すからです。対応するドメインのフィールドがないアームは、他の変換先フィールドと同じくエラーになります。//kanna:map Membership=- でそのアームを除外するか、//kanna:map Kind=- で oneof 全体を除外してください。

直和型

ポインタフィールドでは、ドメインが 2 つのアームを同時に設定するのを防げず、ドメインから出ていく方向では、最初に設定されているものが何も言わずに採用されます。型によって必ず 1 つだけにしたいドメインは、代わりに直和型を持ちます。直和型は、非公開メソッドを持つインターフェース(他のパッケージからは実装できません)と、そのパッケージの公開された型のうち、それを値で実装するものからなります:

type Payment interface{ isPayment() }

type Bank struct{ Account string }
type Cash struct{ Office string }

func (Bank) isPayment() {}
func (Cash) isPayment() {}

type Employee struct {
	Payment Payment // wire: oneof payment { Bank bank = 1; Cash cash = 2; }
}

直和型のフィールドは、同じ名前の oneof フィールドにマッピングされます。各バリアントは、フィールド名、または保持するメッセージの名前がバリアントと同じアームにマッピングされ、大文字小文字とアンダースコアの違いは無視します。値は他の値と同じように変換され、この例では Bank と Cash のために生成された関数を通ります:

switch v := src.Payment.(type) {
case model.Bank:
	dst.Payment = &employeev1.Employee_Bank{Bank: bankToEmployeev1(v)}
case *model.Bank:
	if v != nil {
		dst.Payment = &employeev1.Employee_Bank{Bank: bankToEmployeev1(*v)}
	}
case model.Cash:
	dst.Payment = &employeev1.Employee_Cash{Cash: cashToEmployeev1(v)}
case *model.Cash:
	if v != nil {
		dst.Payment = &employeev1.Employee_Cash{Cash: cashToEmployeev1(*v)}
	}
}

バリアントは値でインターフェースを実装するので、そのポインタも実装します。ポインタはドメインから出ていく方向でバリアントと同じように扱われ、nil のポインタは何も設定しません。ドメインに戻ってくる方向では、oneof に対する型 switch が、そこにあるアームのバリアントを値で設定します。nil の直和型と、どのアームも設定されていない oneof は、互いに対応します。アームのないバリアントはドメインから出ていく方向で、バリアントのないアームはドメインに戻ってくる方向で、生成を止めます。

protobuf の Opaque API で生成された型は、公開フィールドを持たないため、サポートしていません。

フラグ

kanna-mapper [flags] [packages]
フラグ 意味
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

パッケージのデフォルトはカレントディレクトリのパッケージなので、//go:generate go tool kanna-mapper に引数は不要です。成功した実行は何も出力しません。//kanna:mapper のインターフェースを 1 つも見つけられなかった実行は、パッケージをインポートパスで示して警告します。すべてのインターフェースから //kanna:mapper がなくなったパッケージの mapper_gen.go は、実行時に読み込んだパッケージのコードがその関数をまだ呼び出していない限り、削除されます。同じ名前でも生成コードのヘッダがないファイルには決して触れません。削除を行う go generate は open ...: no such file or directory で終わります。これは go generate 自身のエラーで、ジェネレータを実行する前にパッケージのファイルを列挙しているためです。ファイルは意図どおり削除されています(golang/go#36068)。

サンプル

example は、ドメインの集約を protobuf 形式のワイヤ型にマッピングし、またドメインに戻します。フィールドの扱い方をひととおり示しています: uuid.Parse と (*timestamppb.Timestamp).AsTime をそのままリストに挙げた変換、パッケージ間で共有するリストを通じた変換、タグによる名前の変更、map:"-" と //kanna:map による除外、ネストした組のために生成された関数によるマッピング、oneof への集約です。CI はこれを再生成し、出力が変わる場合は失敗します。

開発

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it

ライセンス

MIT