转载

Go 的高级编码和解码技术

高级编码和解码技术

Go 的标准库包含了一些很不错的编码和解码包,里面涵盖了大量的编码方案。一切数据,不管是CSV,XML,JSON,还是 gob —— 一个 Go 特定的编码格式,都涵盖在内,并且,这些包都非常容易上手使用。 事实上,它们中的大多数都不需要再添加任何代码,你只需插入数据,它就会输出编码后的数据。

不过,并不是所有的应用程序都乐于处理这种到 JSON 展现的一对一映射。Struct 标记可以涵盖一些场景中的大多数情况,但如果你使用了很多 API,它的功能还是有限。

例如,你可能会遇到一个 API,它会输出不同的对象到同一个键,使其成为泛型的首选后补对象,但是 Go 并没有这些东西。或者你也可能会使用一个 API,它可以接收并且返回 Unix 时间 而不是 RFC 3339 格式的时间,虽然我们可以在代码中将它表达成一个 int,但是如果可以直接以  time 包的  Time 类型来操作,岂非更好?

在这篇文章中,我们将回顾一些技术,它们可以帮助我们将繁琐的代码简化成相对容易处理的代码。我们会使用 encoding/json 包来做这件事情,而值得注意的是 Go 为大多数编码类型提供了一个 Marshaler 和 Unmarshaler 接口,让你可以在多编码场景中对数据被编码和解码的方式进行自定义。

长期有效的新类型方法

我们要检查的第一种技术是创建一个新类型,并在编码和解码之前将数据和这个类型进行转换。从技术层面说,这不是一个具体的编码方案,但它非常可靠并易于遵循。它属于一项基本技术,后面几节我们也会用到,所以值得你现在花时间看一看。

想像一下,我们的应用是从下面简单的 Dog 类型开始。

type Dog struct {
  ID      int
  Name    string
  Breed   string
  BornAt  time.Time
}

默认情况下, time.Time 类型按 RFC 3339 格式提供。也就是说,它会是一个字符串,类似于 2016-12-07T17:47:35.099008045-05:00。

此类格式没有什么特别的问题,但我们可能希望编码与解码能处于不同的领域。例如,我们可能会使用 API 发送一个 Unix 时间,并期待同样格式的响应。

不管怎样,我们需要有一种办法来改变它转换为 JSON 以及从 JSON 解析的方式。解决方法之一是创建一个 JSONDog 的新类型,并使用它来编码和解码 JSON 。

type JSONDog struct {
  ID     int    `json:"id"`
  Name   string `json:"name"`
  Breed  string `json:"breed"`
  BornAt int64  `json:"born_at"`
}

现在,如果要把 Dog 类型转为 JSON,我们只需要将它转换为 JSONDog 类型,然后使用 encoding/json 包来编排即可。

先从一个接受 Dog 类型参数和返回一个 JSONDog 类型结果的构造函数开始,代码如下:

func NewJSONDog(dog Dog) JSONDog {
  return JSONDog{
    dog.ID,
    dog.Name,
    dog.Breed,
    dog.BornAt.Unix(),
  }
}

把它和 encoding/json 包整合后:

func main() {
  dog := Dog{1, "bowser", "husky", time.Now()}
  b, err := json.Marshal(NewJSONDog(dog))
  if err != nil {
    panic(err)
  }
  fmt.Println(string(b))
}

从 JSON 解码为 Dog 类型的过程也类似。先解码为 JSONDog 类型,然后使用 JSONDog 类型中的 Dog() 方法将它转换回 Dog 类型。

func (jd JSONDog) Dog() Dog {
  return Dog{
    jd.ID,
    jd.Name,
    jd.Breed,
    time.Unix(jd.BornAt, 0),
  }
}
 
func main() {
  b := []byte(`{
    "id":1,
    "name":"bowser",
    "breed":"husky",
    "born_at":1480979203}`)
  var jsonDog JSONDog
  json.Unmarshal(b, &jsonDog)
  fmt.Println(jsonDog.Dog())
}

你可以在 Go 演练场看到完整的代码示例并运行它: https://play.golang.org/p/0hEhCL0ltW

优点:首先,该方法很通用,适合我们构建转换层的情况。 虽然 JSON 部分看起来不像 Go 代码,但我们能对其进行转换。其次,其代码极容易理解,新手也能很好掌握。

但这种方式也是有缺点的,主要体现在两方面:

1、开发人员容易忘记将 Dog 类型转换成 JSONDog 类型

2、它包含很多额外的代码

不过不要灰心,让我们来看看如何在保持代码清晰度的前提下解决这两个问题。

实现 Marshaler 和 Unmarshaler 接口

用最后一种方法会很容易忘记将 Dog 转换为 JSONDog,因此在本节中,我们将讨论如何在 encoding / json 包中实现 Marshaler 和 Unmarshaler 接口,以使转换自动化。

这两个接口的工作方式很简单;当 encoding/json 包遇到一个实现了 Marshaler 接口的类型时,它使用了 MarshalJSON() 的方法代替默认的 marshaling 代码,将对象转换成 JSON。同样地,当解码 JSON 对象时,它将测试该对象是否实现了 Unmarshaler 接口,如果是这样,它会使用 UnmarshalJSON() 方法代替默认的 unmarshaling 行为。我们只需确保 Dog 类型能进行编码与反编码,因为 JSONDog 能帮我们实现这两个方法并做转换。

我们先从编码开始,在 Dog 类型上实现 MarshalJSON() ([]byte, error) 方法。

虽然第一印象是要做好多事情,但实际上我们可以利用已经存在的代码,这样需要我们写的代码就不多了。我们真正需要在这个方法里做的事情只是对当前 Dog 对象的 JSONDog 描述调用 json.Marshal() 方法并返回结果。

func (d Dog) MarshalJSON() ([]byte, error) {
  return json.Marshal(NewJSONDog(d))
}

现在即使开发忘记将 Dog 类型转换为 JSONDog  类型也没关系了,这件事情会在 Dog 转换为 JSON 的时候默认进行。

Unmarshaler 的最终实现非常相似。我们准备实现 UnmarshalJSON([]byte) error 方法,并再一次利用 JSONDog 类型。

func (d *Dog) UnmarshalJSON(data []byte) error {
  var jd JSONDog
  if err := json.Unmarshal(data, &jd); err != nil {
    return err
  }
  *d = jd.Dog()
  return nil
}

最后我们修改一下 main() 函数,在编码和解码时使用 Dog 类型而不是 JSONDog 类型。

func main() {
  dog := Dog{1, "bowser", "husky", time.Now()}
  b, err := json.Marshal(dog)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(b))

  b = []byte(`{
    "id":1,
    "name":"bowser",
    "breed":"husky",
    "born_at":1480979203}`)
  dog = Dog{}
  json.Unmarshal(b, &dog)
  fmt.Println(dog)
}

至此,你可以在 Go 演练场找到可用的示例代码: https://play.golang.org/p/GR6ckydMxF

我们大约只用10行代码就对 Dog 类型重写了默认的 JSON 编码方法,相当简洁,不是吗?

接下来,我们将着手解决使用嵌入数据和别名类型的初始方法中的其它问题。

使用嵌入的数据和别名类型简化代码

注意:这里提到的“别名”与 Go 1.9 的别名提议不同。这个“别名”只是简单的指向某个新类型,它具有与另一种类型相同的数组,但有不同的方法集。

正如我们之前所见,在把所有数据从一种类型复制到另一种类型的时候,定义字段的过程相当乏味。进一步放大来看,如果我们要处理拥有10个或20个字段的对象,保持 JSONDog 和 Dog 类型同步的过程就会让人觉得厌烦。

幸好有另一种方法来解决这个问题,它可以减少需要我们定义的字段,只处理那些需要定义编辑和解码的字段。我们会把 Dog 对象嵌入到 JSONDog 中去,然后自定义一些需要自定义的字段。

一开始需要更新 Dog 类型,为其加入 JSON 标签,这些标签加在需要自定义的字段后面。然后 我们将告诉 / json 包忽略字段,通过使用结构标签  json:"-" 来提醒 JSON 编码器应该忽略这个字段,即使它被导出。

type Dog struct {
  ID     int       `json:"id"`
  Name   string    `json:"name"`
  Breed  string    `json:"breed"`
  BornAt time.Time `json:"-"`
}

接下来,我们把 Dog 类型嵌入到 JSONDog  类型中,并更新 NewJSONDog() 函数和 JSONDog 类型的 Dog() 方法。我们临时把 Dog() 方法改名为 ToDog(),避免与内部的 Dog 对象冲突。

警告:代码现在不能工作,我展示了中间过程来说明其原因。

func NewJSONDog(dog Dog) JSONDog {
  return JSONDog{
    dog,
    dog.BornAt.Unix(),
  }
}

type JSONDog struct {
  Dog
  BornAt int64 `json:"born_at"`
}

func (jd JSONDog) ToDog() Dog {
  return Dog{
    jd.Dog.ID,
    jd.Dog.Name,
    jd.Dog.Breed,
    time.Unix(jd.BornAt, 0),
  }
}

它可以编译,但如果尝试运行的话会产生一个致命错误:堆栈溢出。这发生在调用 Dog 类型的 MarshaJSON() 方法的时候。当函数调用的时候,它会构造一个 JSONDog,但是这个对象内部有一个 Dog 对象,构造 Dog 对象的时候又会构造新的 JSONDog,这就产生了一个无限循环,直到程序崩溃。

为了避免这种情况发生,我们需要创建一个 Dog 类型的别名,它不包含 MarshalJSON() 和 UnmarsshalJSON() 方法。

type DogAlias Dog

拥有了别名类型之后,就可以更新 JSONDog 类型,用它来代替 Dog 类型。我们也需要更新 NewJSONDog(),将 Dog 改为 DogAlias,然后可以清理一下 JSONDOg 类型的 Dog() 方法,将内部的 Dog 作为返回值。

func NewJSONDog(dog Dog) JSONDog {  return JSONDog{
    DogAlias(dog),
    dog.BornAt.Unix(),
  }
}type JSONDog struct {
  DogAlias
  BornAt int64 `json:"born_at"`}func (jd JSONDog) Dog() Dog {
  dog := Dog(jd.DogAlias)
  dog.BornAt = time.Unix(jd.BornAt, 0)  return dog
}

如你所见,初始设置需要大约花了30行代码,但现在我们已经设置好了,Dog 类型中有多少字段并不重要。JSON 代码只会在需要自定义 JSON 的字段增加时才会增长。

你可以在 Go 实验场找到这一节的所有代码: https://play.golang.org/p/N0rweY-cD0

特定字段的自定义类型

上一节提到的方法重点关注了在编码和解码之前将整个对象转换为另一种类型。但即使使用了嵌入的别名,我们仍然会需要对每个具有 time.Time 字段的不同类型的对象重复这段代码。

本节我们会着眼一种方法,使我们能够定义我们需要的单次编码或解码的类型。然后我们会在整个程序中复用这个类型。回到最初的示例,从需要为 BornAt 字段定义 JSON 的 Dog 类型开始。

type Dog struct {
  ID     int       `json:"id"`
  Name   string    `json:"name"`
  Breed  string    `json:"breed"`
  BornAt time.Time `json:"born_at"`}

我们已经知道这不能工作,所以与其使用 time.Time 类型,不如创建自己的 Time 类型,并在其中嵌入 time.Time。现在使用我们新建的 Time 类型更新 Dog 类型。

type Dog struct {
  ID     int    `json:"id"`
  Name   string `json:"name"`
  Breed  string `json:"breed"`
  BornAt Time   `json:"born_at"`}type Time struct {
  time.Time
}

接着,我们开始写为 Time 类型定义的 MarshalJSON() 和 UnmarshalJSON() 方法。新方法分别输出 Unix 时间,或从 Unix 时间解析。

func (t Time) MarshalJSON() ([]byte, error) {
  return json.Marshal(t.Time.Unix())
}

func (t *Time) UnmarshalJSON(data []byte) error {
  var i int64
  if err := json.Unmarshal(data, &i); err != nil {
    return err
  }
  t.Time = time.Unix(i, 0)
  return nil
}

就是这样!我们现在可以在所有结构使用新 Time 类型,它会编码成 Unix 时间或者从 Unix 时间解码。最重要的是,因为嵌入了 time.Time 对象,我们甚至可以在 Time 类型中随意使用 likeDay() 方法,这意味着我们写的代码不需要重写。

这种方法也有缺点。由于采用了一种新类型,我们会破坏那些期望使用 time.Time 而不是我们新定义的 Time 类型的代码。你可以更新所有代码以使用新类型,或者也可以访问嵌入的 time.Time 对象,这可能要求一些重构。

对于这个问题,还有一种方案是将这个方法与我们第一次讨论的方法结合起来,同时取两者的优点 —— Dog 类型拥有一个 time.Time 对象,但 JSONDog 不需要在两种类型转换中操心过多细节。所有转换逻辑都已经包含在的 Time 类型中了。

完整的示例请参考 Go 实验场: https://play.golang.org/p/C272eojwTh

对泛型进行编码和解码

我们要看的最后一种技术与前两种略有不同,因为它解决的问题与前两者完全不同——保存在嵌套 JSON 中的动态类型。

例如,假如你想从服务器获得下面的 JSON 响应:

{
  "data": {
    "object": "bank_account",
    "id": "ba_123",
    "routing_number": "110000000"
  }
}

从同一个终端你可以收到这样的信息:

{
  "data": {
    "object": "card",
    "id": "card_123",
    "last4": "4242"
  }
}

乍一看这两条数据与很相似,但它们是完全不同的对象。你可以用银行账户做什么和可以用卡做什么是完全不同的,在这里根本看不出来,但它们都可能用于差异较大的不同领域。

解决这个问题的方案之一是使用泛型,并在解析 JSON 的时候设置类型。你必须使用反射库,它在某种语言,如 Java 中,会有一些类,如下所示:

class Data<T> {
  public T t;
}
class Card {...}
class BankAccount {...}

然而,Go 没有泛型,那么应该如何解析这个 JSON?

一种办法是使用键为字符串的映射表,但是值应该用什么类型?就算我们假设它是一个嵌套的映射表,如果卡对象包含整数的时候会发生什么事件,如果有嵌套的 JSON 对象又会发生什么事件?

我们的选择确实很受限,但基本上我们会采用空接口(interface {}) 来解决。

func main() {
  jsonStr := `
{
  "data": {
    "object": "card",
    "id": "card_123",
    "last4": "4242"
  }
}
`
  var m map[string]map[string]interface{}
  if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
    panic(err)
  }
  fmt.Println(m)

  b, err := json.Marshal(m)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(b))
}

使用空接口类型会带来相应的设置问题,最值得注意的是,空接口不会提供数据信息。如果我们想要知道数据存储对应的键,我们需要做一种断言,但这很不方便。庆幸地是,还有其他的方法能解决这个问题!

这种方法需要再次利用 Marshaler 和 Unmarshaler 接口,但这次需要添加一些条件逻辑代码,还要使用指向 Card 类型和指向 BankAccount 类型的指针。开始解码 JSON 时,我们将首先解码这两个字段的对象,以确定哪些关键字段需要我们填满,之后再填补上去。

接下来,我们开始声明类型。BankAccount 和 Card 类型是很简单的,我们要将 JSON 直接映射成 Go 的结构体。

type BankAccount struct {
  ID            string `json:"id"`
  Object        string `json:"object"`
  RoutingNumber string `json:"routing_number"`
}

type Card struct {
  ID     string `json:"id"`
  Object string `json:"object"`
  Last4  string `json:"last4"`
}

然后我们就有了自己的数据类型。你可以对它自定义命名,使用 Source 或者 CardOrBankAccount类似的名字会比较好区分。在此我还是使用 Data。

type Data struct {
  *Card
  *BankAccount
}

我在这里使用指针是因为我们不会对这两个数据进行初始化,而是选择其中一个。而你要先确定代码中确实用到了这种类型,然后写一些类似于 if data.Card != nil{...} 的指令来判断当前数据是否是 Card 数据。当然,你也可以将对象的属性存储在数据类型上,但需要注意一些代码的调整 。

现在我们拥有一个 Data 类型的结构,我们需要继续完善 main() 方法,使得将对象映射到 JSON 中的过程更明晰:

func main() {
  jsonStr := `
{
  "data": {
    "object": "card",
    "id": "card_123",
    "last4": "4242"
  }
}
`
  var m map[string]Data
  if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
    panic(err)
  }
  fmt.Println(m)
  data := m["data"]
  if data.Card != nil {
    fmt.Println(data.Card)
  }
  if data.BankAccount != nil {
    fmt.Println(data.BankAccount)
  }

  b, err := json.Marshal(m)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(b))
}

Data 数据并不代表完全的 JSON 结构,而是代表所有存储在 JSON 对象中的键。在我们的代码中,Data 类型拥有 Card 和 BankAccount 两个指针成员,但是在 JSON 中它们不再是嵌套的对象。这就意味着我们需要写一个 MarshalJSON() 方法去反射它:

func (d Data) MarshalJSON() ([]byte, error) {
  if d.Card != nil {
    return json.Marshal(d.Card)
  } else if d.BankAccount != nil {
    return json.Marshal(d.BankAccount)
  } else {
    return json.Marshal(nil)
  }
}

这段代码首先检查我们是否拥有一个 Card 或者 BankAccount 对象。如果有,它将会呈现在 JSON 中相应的对象上。如果两个都没有,它将会以 nil 的形式呈现在 JSON 中,nil 在 JSON 中为 null。

原文  https://www.oschina.net/translate/advanced-encoding-decoding
正文到此结束
Loading...