はじめに
GoでJSONの一部分を利用者が定義した構造体に読み込めるようなAPIの作り方の紹介です。Zabbix APIのクライアントを書いてみて気付きました。
ただ、なんとなく思いついて試してみたら出来たという話で将来にわたって安心して使える手法なのかは不明です。その点はご了承ください。
その手法とは
ZabbixドキュメントのAPIのページを見ると、レスポンスのJSONは
{
"jsonrpc": "2.0",
"result": [
{
"hostid": "10084",
"host": "Zabbix server",
"interfaces": [
{
"interfaceid": "1",
"ip": "127.0.0.1"
}
]
}
],
"id": 2
}
とか
{
"jsonrpc": "2.0",
"result": {
"itemids": [
"24759"
]
},
"id": 3
}
といった感じになります。 result
値の構造がAPIによって違うわけです。
これに対して今回の手法をデモする最小限のコードは以下のような感じです。
https://play.golang.org/p/rmp6ipFc17
package main
import (
"encoding/json"
"fmt"
"log"
)
type response struct {
Jsonrpc string `json:"jsonrpc"`
Result interface{} `json:"result,string"`
ID uint64 `json:"id"`
}
func someFunc(result interface{}) error {
resBytes := []byte(`{"jsonrpc":"2.0","result":{"itemids":["24759"]},"id":3}`)
res := response{Result: result}
return json.Unmarshal(resBytes, &res)
}
func main() {
var result struct {
ItemIDs []string `json:"itemids"`
}
err := someFunc(&result)
if err != nil {
log.Fatal(err)
}
for i, itemID := range result.ItemIDs {
fmt.Printf("itemid[%d]=%s\n", i, itemID)
}
}
response
構造体の Result
フィールドの型を interface{}
にしているのがポイントです。
someFunc()
がライブラリが提供するAPIという想定ですが、 result
を戻り値ではなく interface{}
型の 引数としています。
someFunc()
の中では受け取った result
を response
型のローカル変数 res
の Result
フィールドにセットしてから json.Unmarshal()
で全体の JSON をデコードしています。
APIの利用側は main()
でやっているように、受け取りたいフィールドを持つ構造体の変数を用意して、そのアドレスを渡してAPIを呼び出します。ここでは 10 things you (probably) don't know about Go で紹介されている Anonymous structs を使っていますが、 type
で構造体を定義してから使うことももちろん可能です。
この手法に気づく前は、 Result
フィールドの型を json.RawMessageにしておいて、後で別途バイト配列からデコードするというやり方を考えていました。ですが、この手法ならば全体で一回デコードするだけですみます。
この手法での実装例
この手法を用いて実装してみたZabbix APIのクライアントをhnakamur/go-zabbix: A minimal Zabbix API client for Goに置いています。利用例はgo-zabbix/cmd/example/main.goを参照してください。
APIドキュメントはzabbix - GoDocです。まだ全然書いてないですが。
メソッドの説明
NewClient
NewClientでクライアントを生成します。第1引数 zabbixURL
はZabbixのURLです。
第2引数 zabbixHost
に空文字以外を設定するとリクエストの Host
ヘッダに設定します (GoでhttpリクエストにHostを設定するにはreq.Headerではなくreq.Hostを使う - Qiita 参照)。
URLは http://127.0.0.1/
のようにIPアドレスで指定したいが、ウェブサーバではバーチャルホストで振り分けている場合に使います。
第3引数 logger
は Logger インタフェースの実装を渡すと、リクエストとレスポンスのJSONをログ出力します。 nil
にするとログ出力しません。
Login
Client.Loginがuser.login API用のメソッドです。 Authenticationに説明されていますが、ログインのレスポンスで返された値を、以降のAPI呼び出しのリクエストの auth
フィールドに設定する必要があるため、専用のメソッドとしています。
CallとCallForCount
それ以外のAPIはClient.CallとClient.CallForCountの2つでカバーする想定です。といいつつ、実際カバーできるか確認はしていません。
Method referenceを見ると膨大な数があるので、使う必要に迫られたときに都度確認してカバーできなければ実装を改修すれば良いという考えです。
Client.Callと別にClient.CallForCountを作った理由は、個数だけのレスポンスを受け取るためです。
host.getなどのAPIでは countOutput
パラメータに true
を指定すると {"jsonrpc":"2.0","result":"3","id":3}
のように result
には個数だけを返します。
この値が数値だったら https://play.golang.org/p/-sicHGYu7j のようにしてintの変数受け取れるのですが、 https://play.golang.org/p/4WLOGd6pxc のように文字列をintの変数で受け取ろうとすると json: cannot unmarshal string into Go value of type int
というエラーになってしまいます。
もちろん、 https://play.golang.org/p/pY2RC6Z_Pe のように文字列の変数で受け取る手はあるのですが、その後 strconv.ParseInt で変換する一手間が必要になります。
そこでこの場合だけ https://play.golang.org/p/zju8xBwsED のように response
構造体の Result
フィールドを int64
型にして受け取るようにしました。
実際のコードでは Result
フィールドを除いた部分を responseCommon
という構造体として定義し、それに Result
フィールドを追加した構造体の変数を Anonymous structs で定義して使っています。
タイムスタンプの変換
Appendix 1. Reference commentaryの timestamp
は Unix のタイムスタンプ (1970年1月1日0時0分0秒からの通算秒) の文字列形式となっています。
そこで type Timestamp time.Time
で独自の Timestamp
型を定義し MarshalJSON
と UnmarshalJSON
を実装しています。
この手法の利点
ライブラリ実装を省力化出来る
例えばhistory.get APIの戻り値であるHistory objectはヒストリのデータ型に応じてFloat historyやInteger historyなど5種類の定義があります。
結果を戻り値で返すようにすると、Goのメソッドも GetFloatHistory
, GetIntegerHistory
のように分ける必要が出てきて面倒です。
この手法ならGoの1つのメソッドで多くのAPIをカバーすることが出来るので、コード量が少なくてすみますし、APIの変更にも影響を受けにくいので保守も楽になると思います。
ライブラリ利用者が必要なフィールドだけを持つ型を定義して使うので省メモリ
Call
のシグネチャは
func (c *Client) Call(method string, params, result interface{}) error
となっていて、 interface{}
型なので、これだけ見ても何を渡して良いかわからないという欠点はあります。
ですが、ライブラリ側で真面目に型を定義して回るのも大変です。例えばHost objectを見ても、フィールドの数がとても多いです。APIが変更される度にGoの構造体も追随して更新するのはライブラリの保守が大変です。
それに、そんなに苦労してきちんと構造体を定義してみても、ライブラリの利用者にとってはほとんどのフィールドは不要なのです。それなのにライブラリが提供する構造体のサイズが大きいと、メモリの無駄遣いになってしまいます。
例えばgo-zabbix/cmd/example/main.go内の getHostID
関数では HostID
フィールドのみの構造体の変数を定義して受け取っています。以下のような感じです。これでメモリの無駄遣いを回避できます。
func getHostID(client *zabbix.Client, hostGroupID, hostname string) (hostID string, err error) {
type filter struct {
Host string `json:"host"`
}
params := struct {
GroupIDs string `json:"groupids"`
Filter filter `json:"filter"`
Output []string `json:"output"`
}{
GroupIDs: hostGroupID,
Filter: filter{
Host: hostname,
},
Output: []string{"hostid"},
}
var hosts []struct {
HostID string `json:"hostid"`
}
err = client.Call("host.get", params, &hosts)
if err != nil {
return
}
if len(hosts) == 0 {
err = errNotFound
return
}
hostID = hosts[0].HostID
return
}
また、Zabbix APIではCommon "get" method parametersで説明されていますが output
プロパティに欲しいフィールド名を指定するとそのフィールドだけを取得できるようになっています。必要なフィールドだけ取得するほうが、データ転送量が少なくてすむので活用したいところです。上記の例では output
パラメータに hostid
を指定しています。
ところで、ここではリクエストのパラメータを作るのに anonymous structs の手法に加えて、ネストした構造体の部分を filter
構造体として関数内で定義して使っています。今回知ったのですが関数内でも型定義出来るんですね。Declarations and scopeを見てもGo言語の文法上正しいようです。
利用者側でアプリケーションのニーズに応じて上記のような関数を定義してから使えば、関数のシグネチャもわかりやすいので使い勝手は悪く無いと思います。
この手法の限界
この手法はリクエストに応じてレスポンスの構造が決まる場合はカバーできます。が、レスポンスの一部に依存して、残りの部分の構造が変わる場合はカバーできません。その場合は可変部分を map[string]interface{}
にするか json.RawMessage
にして別途デコードするかの対応が必要になるかと思います。
おわりに
かなり良い感じだと思っていますが、いかがでしょう。他では見たことなかったのですが、みなさんご存知でしたでしょうか。