42
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GoでJSONの一部分を利用者が定義した構造体に読み込める便利な手法を見つけた

Posted at

はじめに

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() の中では受け取った resultresponse 型のローカル変数 resResult フィールドにセットしてから 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引数 loggerLogger インタフェースの実装を渡すと、リクエストとレスポンスのJSONをログ出力します。 nil にするとログ出力しません。

Login

Client.Loginuser.login API用のメソッドです。 Authenticationに説明されていますが、ログインのレスポンスで返された値を、以降のAPI呼び出しのリクエストの auth フィールドに設定する必要があるため、専用のメソッドとしています。

CallとCallForCount

それ以外のAPIはClient.CallClient.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 commentarytimestamp は Unix のタイムスタンプ (1970年1月1日0時0分0秒からの通算秒) の文字列形式となっています。

そこで type Timestamp time.Time で独自の Timestamp 型を定義し MarshalJSONUnmarshalJSON を実装しています。

この手法の利点

ライブラリ実装を省力化出来る

例えば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 にして別途デコードするかの対応が必要になるかと思います。

おわりに

かなり良い感じだと思っていますが、いかがでしょう。他では見たことなかったのですが、みなさんご存知でしたでしょうか。

42
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
42
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?