goでadditionalProperties:trueのように余分なデータを保持したままJSONをmarshal/unmarshalする良い方法がみつからない

時折、余分なデータを保持したままJSONをmarshal/unmarshalしたくなることがある。例えばwebAPIを利用するときなどに、すべてのフィールドを記述するのは面倒だけれど、新しく増えるフィールドの存在に気づかず欠損してしまうのは避けたいみたいな状況。

内部的なクライアントライブラリのようなコードを書きたいときに、この種の思いが頭をよぎる。

ふつうのJSONのmarshall/unmarshal

普通は構造体に含まれない余分なフィールドの値は消える。

例えば以下のようなnameとageだけをフィールドとして持つ構造体を定義し、これに値を埋め込む形でmarshal/unmarshalした場合には、当然ではあるけれど、余分なフィールドは消えてなくなる。

// Person ...
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

nicknameというフィールドも元のJSONには存在する。

$ go run 00loaddump/main.go
input:

{
  "name": "foo",
  "age": 20,
  "nickname": "F"
}


got:
main.Person{Name:"foo", Age:20}

output:

{"name":"foo","age":20}

このときのコードは以下。

00loaddump/main.go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "log"
    "os"
)

// Person ...
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    if err := run(); err != nil {
        log.Fatalf("!%+v", err)
    }
}

func run() error {
    code := `
{
  "name": "foo",
  "age": 20,
  "nickname": "F"
}
`
    fmt.Println("input:")
    fmt.Println(code)
    fmt.Println("")

    o := bytes.NewBufferString(code)
    decoder := json.NewDecoder(o)

    var ob Person
    if err := decoder.Decode(&ob); err != nil {
        return err
    }

    fmt.Println("got:")
    fmt.Printf("%#+v\n", ob)

    fmt.Println("")
    fmt.Println("output:")

    encoder := json.NewEncoder(os.Stdout)
    if err := encoder.Encode(&ob); err != nil {
        return err
    }
    return nil
}

余分なデータを保持したmarshal/unmarshal

さて、このような場合に手軽に余分なデータを保持しておけるならちょっとした手間でもやっておきたい。こういうような振る舞いのもの。

$ go run 01loaddump/main.go
input:

{
  "name": "foo",
  "age": 20,
  "nickname": "F"
}


got:
main.Person{Name:"foo", Age:20, AdditionalProperties:map[string]interface {}{"nickname":"F"}}

output:

{"name":"foo","age":20,"nickname":"F"}

ここでデータ自体は以下の様な形状のもの。nicknameはフィールド定義には含まれない。

// Person ...
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`

    // data additional properties
    AdditionalProperties map[string]interface{} `json:"-"`
}

しかし正攻法を考えると以下のようにコードが途方も無く膨らむ。

UnmarshalJSON()を自分で定義する必要があり、そして自分で記述した部分のフィールドだけからなる構造体を別途内部で作る必要が出てくる。実際のコードは以下の様な形になる(go-swaggerが出力したコードはこのような形になる)。

さすがにこれは、手で記述するには、あまりにもつらい。

// Person ...
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`

    // data additional properties
    AdditionalProperties map[string]interface{} `json:"-"`
}

// UnmarshalJSON unmarshals this object with additional properties from JSON
func (m *Person) UnmarshalJSON(data []byte) error {
    // stage 1, bind the properties
    var stage1 struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    }
    if err := json.Unmarshal(data, &stage1); err != nil {
        return err
    }
    var rcv Person

    rcv.Name = stage1.Name
    rcv.Age = stage1.Age

    *m = rcv

    // stage 2, remove properties and add to map
    stage2 := make(map[string]json.RawMessage)
    if err := json.Unmarshal(data, &stage2); err != nil {
        return err
    }

    delete(stage2, "name")
    delete(stage2, "age")

    // stage 3, add additional properties values
    if len(stage2) > 0 {
        result := make(map[string]interface{})
        for k, v := range stage2 {
            var toadd interface{}
            if err := json.Unmarshal(v, &toadd); err != nil {
                return err
            }
            result[k] = toadd
        }
        m.AdditionalProperties = result
    }

    return nil
}

// MarshalJSON marshals this object with additional properties into a JSON object
func (m Person) MarshalJSON() ([]byte, error) {
    var stage1 struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    }

    stage1.Name = m.Name
    stage1.Age = m.Age

    // make JSON object for known properties
    props, err := json.Marshal(stage1)
    if err != nil {
        return nil, err
    }

    if len(m.AdditionalProperties) == 0 {
        return props, nil
    }

    // make JSON object for the additional properties
    additional, err := json.Marshal(m.AdditionalProperties)
    if err != nil {
        return nil, err
    }

    if len(props) < 3 {
        return additional, nil
    }

    // concatenate the 2 objects
    props[len(props)-1] = ','
    return append(props, additional[1:]...), nil
}

遅くても良いのであればreflectで一定逃げられるが、それでもやっぱりsubsetとなるようなフィールドは定義してあげる必要がある。そして全部をreflectで受ければすべてreflectで記述する必要が出てくる。辛い。

どうしたら気づけるかというとあまり方法がなく、通信結果を記録できるようなroundTripper()などを挟めるようにしておいたり、interceptor的なものを挟めるようにしておくくらいしかない。

(もちろんOpenAPI docを書いておいてコード生成するなどの方法もあるが、本当に一部のAPIだけを利用するために数千行のコードをimportするのは気分が悪いときがある)

最悪なにかライブラリがあれば。。

gist

goのcologのような色付きのloggingをpythonの標準ライブラリのみで行うためのメモ

goのcologのような色付きのloggingをpythonの標準ライブラリのみで行うためのメモ。

colog?

github.com

色付きのログ出力は色々あるけれど。goのcologの表示が手軽で便利そうだったのでこれを参考にすることにした。

このような形で色付きで出力される。

色付き

このときのコード。

main.go

package main

import (
    "log"
    "os"

    "github.com/comail/colog"
)

// https://github.com/comail/colog

func main() {
    colog.Register()
    colog.SetOutput(os.Stdout)
    colog.ParseFields(true)
    colog.SetFlags(log.Ldate | log.Lshortfile)

    log.Print("trace: logging this to stdout")
    log.Print("debug: logging this to stdout")
    log.Print("info: logging this to stdout")
    log.Print("warning: with fields foo=bar")
    log.Print("error: with fields foo=bar")
    log.Print("alert: with fields foo=bar")
}

色の定義自体はこのあたりに書いてある

https://github.com/comail/colog/blob/676c50adc5df1d6649b365a39e8b8dcc17c135e1/std_formatter.go#L14-L30

真似してpythonで実装してみる

いろいろ考えたけれどHandlerで実装するのが一番手軽そうだった。StreamHandlerを拡張する。 (ちなみに他にフックポイントとしてAdapterとFilterがある)

こんな感じのコード。

import logging

logger = logging.getLogger(__name__)

mapping = {
    "TRACE": "[ trace ]",
    "DEBUG": "[ \x1b[0;36mdebug\x1b[0m ]",
    "INFO": "[  \x1b[0;32minfo\x1b[0m ]",
    "WARNING": "[  \x1b[0;33mwarn\x1b[0m ]",
    "WARN": "[  \x1b[0;33mwarn\x1b[0m ]",
    "ERROR": "\x1b[0;31m[ error ]\x1b[0m",
    "ALERT": "\x1b[0;37;41m[ alert ]\x1b[0m",
    "CRITICAL": "\x1b[0;37;41m[ alert ]\x1b[0m",
}


class ColorfulHandler(logging.StreamHandler):
    def emit(self, record: logging.LogRecord) -> None:
        record.levelname = mapping[record.levelname]
        super().emit(record)


logging.basicConfig(handlers=[ColorfulHandler()], level=logging.DEBUG)

logger.debug("hello")
logger.info("hello")
logger.warning("hello")
logger.error("hello")
logger.critical("hello")

(pythonにはalertがなくcriticalがあるのでcriticalをalertということにしてみた)

はい。

色付き

追記

表示をよりcologに寄せるために修正した(diffはgistの履歴)。

gist