Go-网络编程-二-

79 阅读33分钟

Go 网络编程(二)

原文:Network programming with Go

协议:CC BY-NC-SA 4.0

四、数据序列化

客户端和服务器需要通过消息交换信息。TCP 和 UDP 提供了实现这一点的传输机制。这两个过程还需要有一个合适的协议,这样消息交换才能有意义地进行。

消息作为字节序列在网络上发送,除了作为线性字节流之外,它没有任何结构。我们将在下一章讨论消息的各种可能性以及定义它们的协议。在这一章中,我们集中讨论消息的一个组成部分——被传输的数据。

程序通常会构建复杂的数据结构来保存当前的程序状态。在与远程客户端或服务进行对话时,程序将试图通过网络传输这种数据结构——也就是说,在应用程序自己的地址空间之外。

结构数据

编程语言使用结构化数据,如下所示:

  • 记录/结构
  • 变体记录
  • 数组:固定大小或可变大小
  • 字符串:固定大小或可变大小
  • 表格:记录数组
  • 非线性结构,例如
    • 循环链表
    • 二叉树
    • 引用其他对象的对象

IP、TCP 或 UDP 数据包都不知道这些数据类型的含义。它们所能包含的只是一个字节序列。因此,应用程序必须将任何数据序列化为字节流才能写入数据,并在读取数据时将字节流反序列化为合适的数据结构。这两个操作称为编组和解组,分别为 1

例如,考虑发送以下两列可变长度字符串的可变长度表:

| 图像读取器设备(figure-reader electronic device 的缩写) | 节目编排者 | | 黎平 | 分析家 | | 一定的 | 经理 |

这可以通过各种方式来实现。例如,假设已知数据将是两列表中未知数量的行。那么编组的表单可以是:

3                // 3 rows, 2 columns assumed
4 fred           // 4 char string,col 1
10 programmer    // 10 char string,col 2
6 liping         // 6 char string, col 1
7 analyst        // 7 char string, col 2
8 sureerat       // 8 char string, col 1
7 manager        // 7 char string, col 2

可变长度的东西也可以用一个“非法”的值来表示它们的长度,比如用\0来表示字符串。前面的表也可以用行数来写,但是每个字符串都以\0结束(换行符是为了可读性,不是序列化的一部分):

3
fred\0        
programmer\0
liping\0
analyst\0
sureerat\0
manager\0

或者,可以知道数据是一个三行的固定表,包含两列长度分别为 8 和 10 的字符串。那么表的序列化可以是(换行符也不是序列化的一部分):

fred\0\0\0\0
programmer
liping\0\0
analyst\0\0\0
sureerat
manager\0\0\0

这些格式都可以,但是消息交换协议必须指定使用哪种格式,或者允许在运行时确定使用哪种格式。

双方协议

上一节概述了数据序列化的问题。实际上,细节可能要复杂得多。例如,考虑第一种可能性,将一个表编组到流中:

3
4 fred
10 programmer
6 liping
7 analyst
8 sureerat
7 manager

许多问题出现了。例如,表可能有多少行——也就是说,我们需要多大的整数来描述行大小?如果它小于或等于 255,那么单个字节就可以了,但是如果它大于 255,那么就需要一个短整型、整型或长整型。每个字符串的长度也会出现类似的问题。对于字符本身,它们属于哪个字符集?7 位 ASCII 码?16 位 Unicode?字符集的问题将在后面的章节中详细讨论。

这种序列化是不透明的或隐式的。如果数据是用这种格式编排的,那么在序列化的数据中就没有说明应该如何对其进行解组。为了正确地解组数据,解组方必须确切地知道数据是如何序列化的。例如,如果行数被封送为 8 位整数,但被解组为 16 位整数,那么当接收方试图将 3 和 4 解组为 16 位整数时,将会出现不正确的结果,并且接收程序稍后几乎肯定会失败。

早期一个众所周知的序列化方法是 Sun 的 RPC 使用的 XDR(外部数据表示),后来被称为 ONC(开放网络计算)。RFC 1832 定义了 XDR,了解这一规格有多精确很有意义。尽管如此,XDR 本质上是类型不安全的,因为序列化数据不包含类型信息。它在 ONC 中使用的正确性主要是由编译器为编组和解组生成代码来保证的。

Go 不包含对不透明序列化数据的编组和解组的显式支持。Go 中的 RPC 包不使用 XDR,而是使用 Gob 序列化,这将在本章后面描述。

自描述数据

自描述数据携带数据的类型信息。例如,以前的数据可能会被编码如下:

table
   uint8 3
   uint 2
string
   uint8 4
   []byte fred
string
   uint8 10
   []byte programmer
string
   uint8 6
   []byte liping
string
   uint8 7
   []byte analyst
string
   uint8 8
   []byte sureerat
string
   uint8 7
   []byte manager

当然,真正的编码通常不会像示例中那样麻烦和冗长:小整数将被用作类型标记,并且整个数据将被打包在尽可能小的字节数组中。(不过,XML 提供了一个反例。)但是,原理是封送拆收器将在序列化数据中生成这样的类型信息。解组器将知道类型生成规则,并将能够使用它们来重建正确的数据结构。

ASN.1

抽象语法符号一(ASN.1)最初是在 1984 年为电信行业设计的。ASN.1 是一个复杂的标准,它的一个子集在包asn1中被 Go 支持。它从复杂的数据结构中构建自描述的序列化数据。它在当前网络系统中的主要用途是作为 X.509 证书的编码,在认证系统中大量使用。Go 中的支持基于读写 X.509 证书所需的内容。

两个函数允许我们编组和解组数据:

func Marshal(val interface{}) ([]byte, error)
func Unmarshal(val interface{}, b []byte) (rest []byte, err error)

第一个函数将数据值封送到一个序列化的字节数组中,第二个函数将它解组。但是,类型接口的第一个参数值得进一步研究。给定一个类型的变量,我们可以通过传递它的值来封送它。为了解组它,我们需要一个命名类型的变量来匹配序列化的数据。具体细节将在后面讨论。但是我们还需要确保变量被分配给该类型的内存,这样实际上就有现有的内存供解组写入值。

我们在ASN1.go中用一个简单的例子来说明一个整数的编组和解组。我们可以将一个整数值传递给marshal以返回一个字节数组,并将该数组解组为一个整数变量,如下所示:

/* ASN1
 */

package main

import (
        "encoding/asn1"
        "fmt"
        "os"
)

func main() {
        val := 13
        fmt.Println("Before marshal/unmarshal: ", val)
        mdata, err := asn1.Marshal(val)
        checkError(err)

        var n int
        _, err1 := asn1.Unmarshal(mdata, &n)
        checkError(err1)

        fmt.Println("After marshal/unmarshal: ", n)
}

func checkError(err error) {
        if err != nil {
                fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
                os.Exit(1)
        }
}

该程序运行如下:

go run ASN1.go

解组值,当然是13

一旦我们超越了这一点,事情就变得更难了。为了管理更复杂的数据类型,我们必须更仔细地研究 ASN.1 支持的数据结构,以及 ASN.1 在 Go 中是如何支持的。

任何序列化方法都能够处理某些数据类型,而不能处理其他一些数据类型。因此,为了确定任何序列化(如 ASN.1)的适用性,您必须查看可能支持的数据类型和您希望在应用程序中使用的数据类型。以下 ASN.1 类型摘自 http://www.obj-sys.com/asn1tutorial/node4.html

简单类型如下:

  • BOOLEAN:双态变量值
  • INTEGER:模型整型变量值
  • BIT STRING:模拟任意长度的二进制数据
  • OCTET STRING:模拟长度为 8 的倍数的二进制数据
  • NULL:表示序列元素的有效缺失
  • OBJECT IDENTIFIER:命名信息对象
  • REAL:模拟实变量值
  • ENUMERATED:用至少三种状态模拟变量值
  • CHARACTER STRING:模拟指定字符集中的字符串值

字符串可以来自某些字符集:

  • NumericString : 0,1,2,3,4,5,6,7,8,9,空格
  • PrintableString:大小写字母、数字、空格、撇号、左/右括号、加号、逗号、连字符、句号、实线、冒号、等号和问号
  • TeletexString (T61String):CCITT ?? 中的 Teletex 字符集,空格,删除
  • VideotexString:CCITT 的 T.100 和 T.101 中的可视图文字符集,空格,删除
  • VisibleString(输入字符串):打印国际 ASCII 字符集,空格
  • IA5String:国际字母表 5(国际 ASCII)
  • GraphicString 25:所有注册的 G 组,空格GraphicString
  • 除此之外,还有其他字符串类型,特别是UTF8String

最后,还有结构化类型:

  • SEQUENCE:对不同类型变量的有序集合建模
  • SEQUENCE OF:对同一类型变量的有序集合进行建模
  • 对不同类型的变量的无序集合建模
  • SET OF:对同一类型变量的无序集合建模
  • CHOICE:指定不同类型的集合,从中选择一种类型
  • SELECTION:从指定的CHOICE类型中选择一个组件类型
  • ANY:允许应用程序指定类型

Note

ANY是不推荐使用的 ASN.1 结构化类型。已经换成 X.680 开放式了。

并不是所有这些都是 Go 支持的。Go 并不支持所有可能的值。Go asn1包文档中给出的规则如下:

  • ASN.1 INTEGER可以写入到intint64中。如果编码值不符合 Go 类型,Unmarshal返回一个解析错误。
  • ASN.1 BIT STRING可以写入到BitString中。
  • ASN.1 OCTET STRING可以写入到[]byte中。
  • ASN.1 OBJECT IDENTIFIER可以写入到ObjectIdentifier中。
  • ASN.1 ENUMERATED可以写入到Enumerated中。
  • ASN.1 UTCTIMEGENERALIZEDTIME可以被写入*time.Time
  • ASN.1 PrintableStringIA5String可以写入字符串。
  • 任何上述 ASN.1 值都可以写入一个interface{}。存储在接口中的值具有相应的 Go 类型。对于整数,该类型是int64
  • 如果可以将x写入片的元素类型,则可以将 ASN.1 SEQUENCE OF xSET OF x写入片。
  • 如果序列中的每个元素都可以写入结构中的相应元素,则 ASN.1 SEQUENCESET可以写入 Go 结构。

Go 对 ASN.1 进行了真正的限制,比如 ASN.1 允许任意大小的整数,而 Go 实现最多只允许有符号的 64 位整数。另一方面,Go 区分有符号和无符号类型,而 ASN.1 不区分。例如,如果值uint64对于int64来说太大,传输可能会失败。

同理,ASN.1 允许几种不同的字符集,而 Go 包声明只支持PrintableStringIA5String (ASCII)。ASN.1 现在有了 Unicode UTF8 字符串类型,Go 也支持这种类型,但目前还没有文档。

我们已经看到,像整数这样的值可以很容易地进行编组和解组。其他基本类型如布尔型和实数型也可以类似地处理。完全由 ASCII 字符或 UTF8 字符组成的字符串可以被编组和解组。只要字符串仅由 ASCII 或 UTF8 字符组成,此代码就有效:

s := "hello"
mdata, _ := asn1.Marshal(s)

var newstr string
asn1.Unmarshal(mdata, &newstr)

ASN.1 还包括一些不在这个列表中的“有用类型”,比如 UTC 时间。Go 支持此 UTC 时间类型。这意味着您可以以一种其他数据值不可能的方式传递时间值。ASN.1 不支持指针,但是 Go 有专门的代码来管理指向时间值的指针。函数Now()返回*time.Time。特殊的代码对此进行整理,并且可以将其解组到一个指向time.Time对象的指针变量中。因此,这段代码是有效的:

t := time.Now()
mdata, err := asn1.Marshal(t)

var newtime = new(time.Time)
_, err1 := asn1.Unmarshal(newtime, mdata)

LocalTimenew都处理指向一个*time.Time的指针,而 Go 处理这个特例。程序ASN1basic.go说明了这些:

/* ASN.1 Basic
 */

package main

import (
        "encoding/asn1"
        "fmt"
        "os"
        "time"
)

func main() {

        t := time.Now()
        fmt.Println("Before marshalling: ", t.String())

        mdata, err := asn1.Marshal(t)
        checkError(err)
        fmt.Println("Marshalled ok")

        var newtime = new(time.Time)
        _, err1 := asn1.Unmarshal(mdata, newtime)
        checkError(err1)

        fmt.Println("After marshal/unmarshal: ", newtime.String())

        s := "hello \u00bc"
        fmt.Println("Before marshalling: ", s)

        mdata2, err := asn1.Marshal(s)
        checkError(err)
        fmt.Println("Marshalled ok")

        var newstr string
        _, err2 := asn1.Unmarshal(mdata2, &newstr)
        checkError(err2)

        fmt.Println("After marshal/unmarshal: ", newstr)

}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

当它如下运行时:

go run ASN1basic.go

它打印出类似这样的内容:

Before marshalling:  2017-03-02 22:31:16.878943019 +1100 AEDT
Marshalled ok
After marshal/unmarshal:  2017-03-02 22:31:16 +1100 AEDT
Before marshalling:  hello ¼
Marshalled ok
After marshal/unmarshal:  hello ¼

一般来说,您可能想要封送和解封送结构。除了时间这个特例,Go 会很乐意处理结构,但是不会处理指向结构的指针。像new这样的操作会创建指针,所以您必须在编组/解组它们之前解引用它们。Go 通常会在需要时解引用指针,但在这种情况下不会,所以您必须显式地解引用它们。这两者都适用于一种类型T:

// using variables
var t1 T
t1 = ...
mdata1, _ := asn1.Marshal(t)

var newT1 T
asn1.Unmarshal(&newT1, mdata1)

// using pointers
var t2 = new(T)
*t2 = ...
mdata2, _ := asn1.Marshal(*t2)

var newT2 = new(T)
asn1.Unmarshal(newT2, mdata2)

指针和变量的任何合适的组合都可以。这里我们没有给出完整的例子,因为应用这些规则应该足够简单。

结构中的所有字段都必须是可导出的,也就是说,字段名必须以大写字母开头。Go 使用反射包来编组/解组结构,因此它必须能够检查所有字段。无法封送此类型:

type T struct {
    Field1 int
    field2 int // not exportable
}

ASN.1 只处理数据类型。它不考虑结构字段的名称。因此,下面的类型 T1 可以被编组/解组到类型T2中,因为对应的字段是相同的类型:

type T1 struct {
    F1 int
    F2 string
}

type T2 struct {
    FF1 int
    FF2 string
}

不仅每个字段的类型必须匹配,数量也必须匹配。这两种类型不起作用:

type T1 struct {
    F1 int
}

type T2 struct {
    F1 int
    F2 string // too many fields
}

我们没有给出完整的代码示例,因为我们不会用到这些特性。

ASN.1 说明了实现序列化方法的人可以做出的许多选择。可以通过使用更多的代码对指针进行特殊处理,例如强制名称匹配。字符串的顺序和数量将取决于序列化规范的细节、它所允许的灵活性以及利用这种灵活性所需的编码工作。值得注意的是,其他序列化格式会做出不同的选择,不同语言的实现也会强制执行不同的规则。

ASN.1 日间客户端和服务器

现在(最后)让我们转向使用 ASN.1 跨网络传输数据。

我们可以使用上一章的技术编写一个 TCP 服务器,以 ASN.1 Time的形式提供当前时间。一个服务器是ASNDaytimeServer.go:

/* ASN1 DaytimeServer
 */
package main

import (
        "encoding/asn1"
        "fmt"
        "net"Calibri
        "os"
        "time"
)

func main() {

        service := ":1200"
        tcpAddr, err := net.ResolveTCPAddr("tcp", service)
        checkError(err)

        listener, err := net.ListenTCP("tcp", tcpAddr)
        checkError(err)

        for {
                conn, err := listener.Accept()
                if err != nil {
                        continue
                }

                daytime := time.Now()
                // Ignore return network errors.
                mdata, _ := asn1.Marshal(daytime)
                conn.Write(mdata)
                conn.Close() // we're finished
        }
}

func checkError(err error) {
        if err != nil {
                fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
                os.Exit(1)
        }
}

这可以编译成一个可执行文件,比如ASN1DaytimeServer,并且不带参数运行。它将等待连接,然后将时间作为 ASN.1 字符串发送给客户端。

一个客户是ASNDaytimeClient.go:

/* ASN.1 DaytimeClient
 */
package main

import (
        "bytes"
        "encoding/asn1"
        "fmt"
        "io"
        "net"
        "os"
        "time"
)

func main() {
        if len(os.Args) != 2 {
                fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
                os.Exit(1)
        }
        service := os.Args[1]

        conn, err := net.Dial("tcp", service)

        checkError(err)

        result, err := readFully(conn)
        checkError(err)

        var newtime time.Time
        _, err1 := asn1.Unmarshal(result, &newtime)
        checkError(err1)

        fmt.Println("After marshal/unmarshal: ", newtime.String())

        os.Exit(0)
}

func checkError(err error) {
        if err != nil {
                fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
                os.Exit(1)
        }
}

func readFully(conn net.Conn) ([]byte, error) {
        defer conn.Close()

        result := bytes.NewBuffer(nil)
        var buf [512]byte
        for {
                n, err := conn.Read(buf[0:])
                result.Write(buf[0:n])
                if err != nil {
                        if err == io.EOF {
                                break
                        }
                        return nil, err
                }
        }
        return result.Bytes(), nil
}

它连接到以类似于localhost:1200的形式给出的服务,读取 TCP 包,并将 ASN.1 内容解码成一个字符串,然后打印出来。

注意,这两者——客户机或服务器——都与上一章中基于文本的客户机和服务器不兼容。这个客户端和服务器正在交换 ASN.1 编码的数据值,而不是文本字符串。

数据

JSON 代表 JavaScript 对象符号。它被设计成一种在 JavaScript 系统之间传递数据的轻量级方法。它使用基于文本的格式,非常通用,已经成为许多编程语言的通用序列化方法。

JSON 序列化对象、数组和基本值。基本值包括字符串、数字、布尔值和空值。数组是以逗号分隔的值列表,可以表示数组、向量、列表或各种编程语言的序列。它们由方括号[ ... ]分隔。对象由花括号{ ... }中的“字段:值”对列表表示。

例如,前面给出的雇员表可以写成一个雇员对象数组:

[
   {"Name": "fred", "Occupation": "programmer"},
   {"Name": "liping", "Occupation": "analyst"},
   {"Name": "sureerat", "Occupation": "manager"}
]

对于复杂的数据类型(如日期、数字类型之间没有区别、没有递归类型等)没有特殊的支持。JSON 是一种非常简单的语言,但是仍然非常有用。它基于文本的格式使它易于使用和调试,尽管它有字符串处理的开销。

根据 Go JSON 包规范,编组使用以下类型相关的默认编码:

  • 布尔值编码为 JSON 布尔值。
  • 浮点和整数值编码为 JSON 数字。
  • 字符串值编码为 JSON 字符串,每个无效的 UTF-8 序列由 Unicode 替换字符 U+FFFD 的编码替换。
  • 数组和切片值编码为 JSON 数组,除了[]byte编码为 Base64 编码的字符串。
  • 结构值编码为 JSON 对象。每个结构字段都成为对象的成员。默认情况下,对象的键名是转换为小写的结构字段名。如果 struct 字段有标记,则该标记将被用作名称。
  • 映射值编码为 JSON 对象。映射的键类型必须是字符串;对象键直接用作贴图键。
  • 指针值编码为所指向的值。(注意:这允许树,但不允许图!).nil 指针编码为空 JSON 对象。
  • 接口值编码为接口中包含的值。nil 接口值编码为空 JSON 对象。
  • 通道、复杂和函数值不能在 JSON 中编码。试图对这样的值进行编码会导致Marshal返回InvalidTypeError
  • JSON 不能表示循环数据结构,并且Marshal不处理它们。将循环结构传递给Marshal将导致无限递归。

将 JSON 序列化数据存储到文件person.json中的程序是SaveJSON.go:

/* SaveJSON
 */

package main

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

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string
}

func main() {
        person := Person{
                Name: Name{Family: "Newmarch", Personal: "Jan"},
                Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
                        Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}

        saveJSON("person.json", person)
}

func saveJSON(fileName string, key interface{}) {
        outFile, err := os.Create(fileName)
        checkError(err)
        encoder := json.NewEncoder(outFile)
        err = encoder.Encode(key)
        checkError(err)
        outFile.Close()
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }

}

要将其加载回内存,请使用LoadJSON.go:

/* LoadJSON
 */

package main

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

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string
}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}
func main() {
        var person Person
        loadJSON("person.json", &person)

        fmt.Println("Person", person.String())
}

func loadJSON(fileName string, key interface{}) {
        inFile, err := os.Open(fileName)
        checkError(err)
        decoder := json.NewDecoder(inFile)
        err = decoder.Decode(key)
        checkError(err)
        inFile.Close()
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)

        }
}

序列化形式(格式良好):

{"Name":{"Family":"Newmarch",
         "Personal":"Jan"},
 "Email":[{"Kind":"home","Address":"jan@newmarch.name"},
          {"Kind":"work","Address":"j.newmarch@boxhill.edu.au"}
         ]
}

客户端和服务器

一个客户端发送一个人的数据并读回 10 次是JSONEchoClient.go:

/* JSON EchoClient
 */
package main

import (
        "bytes"
        "encoding/json"
        "fmt"
        "io"
        "net"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string

}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}

func main() {
        person := Person{
                Name: Name{Family: "Newmarch", Personal: "Jan"},
                Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
                        Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}

        if len(os.Args) != 2 {
                fmt.Println("Usage: ", os.Args[0], "host:port")
                os.Exit(1)
        }
        service := os.Args[1]

        conn, err := net.Dial("tcp", service)
        checkError(err)

        encoder := json.NewEncoder(conn)
        decoder := json.NewDecoder(conn)

        for n := 0; n < 10; n++ {
                encoder.Encode(person)
                var newPerson Person
                decoder.Decode(&newPerson)
                fmt.Println(newPerson.String())
        }

        os.Exit(0)
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

func readFully(conn net.Conn) ([]byte, error) {
        defer conn.Close()

        result := bytes.NewBuffer(nil)
        var buf [512]byte
        for {
                n, err := conn.Read(buf[0:])
                result.Write(buf[0:n])
                if err != nil {
                        if err == io.EOF {
                                break
                        }
                        return nil, err

                }
        }
        return result.Bytes(), nil
}

对应的服务器是JSONEchoServer.go:

/* JSON EchoServer
 */
package main

import (
        "encoding/json"
        "fmt"
        "net"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string
}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}

func main() {

        service := "0.0.0.0:1200"
        tcpAddr, err := net.ResolveTCPAddr("tcp", service)
        checkError(err)

        listener, err := net.ListenTCP("tcp", tcpAddr)
        checkError(err)

        for {
                conn, err := listener.Accept()
                if err != nil {
                        continue
                }

                encoder := json.NewEncoder(conn)
                decoder := json.NewDecoder(conn)

                for n := 0; n < 10; n++ {
                        var person Person
                        decoder.Decode(&person)
                        fmt.Println(person.String())
                        encoder.Encode(person)
                }
                conn.Close() // we're finished
        }
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

Gob 包

Gob 是一种特定于 Go 的序列化技术。它是专门为编码 Go 数据类型而设计的,目前还没有其他语言的实质性支持。它支持除通道、函数和接口之外的所有 Go 数据类型。它支持所有类型和大小的整数、字符串和布尔值、结构、数组和切片。目前,它在环形结构方面存在一些问题,但随着时间的推移会有所改善。

Gob 将类型信息编码成它的序列化形式。这比 X.509 序列化中的类型信息要广泛得多,但比 XML 文档中包含的类型信息要有效得多。对于每一段数据,类型信息只包括一次,但包括例如结构字段的名称。

这种类型信息的包含使得 Gob 编组和解组对于编组器和解组器之间的变化或差异相当健壮。例如,此结构:

 struct T {
     a int
     b int
}

可以被编组,然后解组到不同的结构中,其中字段的顺序已经改变:

 struct T {
     b int
     a int
}

它还可以处理丢失的字段(值被忽略)或额外的字段(字段保持不变)。它可以处理指针类型,因此前面的结构可以被解组到这个结构中:

 struct T {
     *a int
     **b int
}

在某种程度上,它可以处理类型强制,使int字段可以扩展为int64,但不能处理不兼容的类型,如intuint

要使用 Gob 编组数据值,首先需要创建一个Encoder。它将一个Writer作为参数,并将对这个写流进行编组。编码器有一个名为Encode的方法,它将值封送到流中。可以对多段数据多次调用此方法。但是,每种数据类型的类型信息只写一次。

您使用一个Decoder来解组序列化的数据流。这需要一个Reader,每次读取都返回一个解组的数据值。

将 Gob 序列化数据存储到文件person.go中的程序是SaveGob.go:

/* SaveGob
 */

package main

import (
        "encoding/gob"
        "fmt"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string

}

func main() {
        person := Person{
                Name: Name{Family: "Newmarch", Personal: "Jan"},
                Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
                        Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}

        saveGob("person.gob", person)
}

func saveGob(fileName string, key interface{}) {
        outFile, err := os.Create(fileName)
        checkError(err)
        encoder := gob.NewEncoder(outFile)
        err = encoder.Encode(key)
        checkError(err)
        outFile.Close()
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

要将其加载回内存,请使用LoadGob.go:

/* LoadGob
 */

package main

import (
        "encoding/gob"
        "fmt"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string
}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}
func main() {
        var person Person

        loadGob("person.gob", &person)

        fmt.Println("Person", person.String())
}

func loadGob(fileName string, key interface{}) {
        inFile, err := os.Open(fileName)
        checkError(err)
        decoder := gob.NewDecoder(inFile)
        err = decoder.Decode(key)
        checkError(err)
        inFile.Close()
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

客户端和服务器

一个客户端发送一个人的数据并读回 10 次是GobEchoClient.go:

/* Gob EchoClient
 */
package main

import (
        "bytes"
        "encoding/gob"
        "fmt"
        "io"
        "net"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string

}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}

func main() {
        person := Person{
                Name: Name{Family: "Newmarch", Personal: "Jan"},
                Email: []Email{Email{Kind: "home", Address: "jan@newmarch.name"},
                        Email{Kind: "work", Address: "j.newmarch@boxhill.edu.au"}}}

        if len(os.Args) != 2 {
                fmt.Println("Usage: ", os.Args[0], "host:port")
                os.Exit(1)
        }
        service := os.Args[1]

        conn, err := net.Dial("tcp", service)
        checkError(err)

        encoder := gob.NewEncoder(conn)
        decoder := gob.NewDecoder(conn)

        for n := 0; n < 10; n++ {
                encoder.Encode(person)
                var newPerson Person
                decoder.Decode(&newPerson)
                fmt.Println(newPerson.String())
        }

        os.Exit(0)
}

func checkError(err error) {

        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

func readFully(conn net.Conn) ([]byte, error) {
        defer conn.Close()

        result := bytes.NewBuffer(nil)
        var buf [512]byte
        for {
                n, err := conn.Read(buf[0:])
                result.Write(buf[0:n])
                if err != nil {
                        if err == io.EOF {
                                break
                        }
                        return nil, err
                }
        }
        return result.Bytes(), nil
}

对应的服务器是GobEchoServer.go:

/* Gob EchoServer
 */
package main

import (
        "encoding/gob"
        "fmt"
        "net"
        "os"
)

type Person struct {
        Name  Name
        Email []Email
}

type Name struct {
        Family   string
        Personal string
}

type Email struct {
        Kind    string
        Address string

}

func (p Person) String() string {
        s := p.Name.Personal + " " + p.Name.Family
        for _, v := range p.Email {
                s += "\n" + v.Kind + ": " + v.Address
        }
        return s
}

func main() {

        service := "0.0.0.0:1200"
        tcpAddr, err := net.ResolveTCPAddr("tcp", service)
        checkError(err)

        listener, err := net.ListenTCP("tcp", tcpAddr)
        checkError(err)

        for {
                conn, err := listener.Accept()
                if err != nil {
                        continue
                }

                encoder := gob.NewEncoder(conn)
                decoder := gob.NewDecoder(conn)

                for n := 0; n < 10; n++ {
                        var person Person
                        decoder.Decode(&person)
                        fmt.Println(person.String())
                        encoder.Encode(person)
                }
                conn.Close() // we're finished
        }
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

将二进制数据编码为字符串

从前,传输 8 位数据是有问题的。它通常通过嘈杂的串行线路传输,很容易被破坏。另一方面,7 位数据可以更可靠地传输,因为第 8 位可以用作校验位。例如,在“偶数奇偶校验”方案中,校验位将被设置为 1 或 0,以使一个字节中的 1 为偶数。这允许检测每个字节中单个位的错误。

ASCII 是 7 位字符集。已经开发了许多比简单的奇偶校验更复杂的方案,但是这些方案涉及将 8 位二进制数据转换成 7 位 ASCII 格式。本质上,8 位数据以某种方式扩展到 7 位字节。

在 HTTP 响应和请求中传输的二进制数据通常被转换成 ASCII 形式。这使得用一个简单的文本阅读器检查 HTTP 消息变得很容易,而不用担心奇怪的 8 位字节会对您的显示产生什么影响!

一种常见的格式是 Base64。Go 支持许多二进制到文本的格式,包括 Base64。

Base64 编码和解码有两个主要函数:

func NewEncoder(enc *Encoding, w io.Writer) io.WriteCloser
func NewDecoder(enc *Encoding, r io.Reader) io.Reader

一个简单的编码和解码八个二进制数字的程序是:

/**
 * Base64
 */

package main

import (
        "encoding/base64"
        "fmt"
)

func main() {

        eightBitData := []byte{1, 2, 3, 4, 5, 6, 7, 8}

        enc := base64.StdEncoding.EncodeToString(eightBitData)
        dec, _ := base64.StdEncoding.DecodeString(enc)

        fmt.Println("Original data ", eightBitData)
        fmt.Println("Encoded string ", enc)
        fmt.Println("Decoded data ", dec)
}

输出如下所示:

Original data  [1 2 3 4 5 6 7 8]
Encoded string  AQIDBAUGBwg=
Decoded data  [1 2 3 4 5 6 7 8]

协议缓冲区

到目前为止,考虑的序列化方法分为多种类型:

  • ASN.1 使用数据中的二进制标签对不同类型进行编码。从这个意义上说,ASN.1 编码的数据结构是一种自描述结构。
  • JSON 同样是一种自描述格式,使用 JavaScript 数据结构的规则:列表、字典等。
  • Gob 同样将类型信息编码成它的编码形式。这比 JSON 格式要详细得多。

另一类序列化技术依赖于要编码的数据类型的外部规范。有几个主要的,比如 ONC RPC 使用的编码。

ONC RPC 是一种旧的编码,面向 C 语言。最近的一个来自谷歌,被称为协议缓冲区。Go 标准库中不支持这一点,但 Google Protocol Buffers 开发小组( https://developers.google.com/protocol-buffers/ )支持这一点,而且这一点在 Google 内部显然非常流行。出于这个原因,我们包括了一个关于协议缓冲区的部分,尽管在本书的其余部分我们通常处理 Go 标准库。

协议缓冲区是数据的二进制编码,旨在支持多种语言的数据类型。它们依赖于数据结构的外部规范,该规范用于编码数据(用源语言)以及将编码的数据解码回目标语言。(注:协议缓冲区于 2016 年 7 月过渡到版本 3。它与版本 2 不兼容。版本 2 将会被长期支持,但最终会被淘汰。参见 https://github.com/google/protobuf/releases/tag/v3.0.0 的协议缓冲区 3.0.0 版)。

要序列化的数据结构称为消息。每个消息中支持的数据类型包括:

  • 数字(整数或浮点数)
  • 布尔运算
  • 弦乐(UTF 语-8)
  • 原始字节
  • 地图
  • 其他消息,允许构建复杂的数据结构

消息的所有字段都是可选的(这是从proto2开始的变化,在那里字段是必需的或可选的)。字段可以代表关键字 repeated 的列表或数组,也可以代表使用关键字 map 的映射。每个字段都有一个类型,后跟一个名称,再后跟一个标记索引值。完整的语言指南称为“协议缓冲区语言指南”(参见 https://developers.google.com/protocol-buffers/docs/proto )。

消息的定义与可能的目标语言无关。协议缓冲区版本 3 的语法中的Person类型的一个版本是personv3.proto。请注意,该文件包含每种类型的特定标记(1,2)。

syntax = "proto3";
package person;

message Person {
        message Name {
                string family = 1;
                string personal = 2;
        }

        message Email {
                string kind = 1;
                string address = 2;
        }

        Name  name = 1;
        repeated Email email = 2;
}

安装和编译协议缓冲区

使用名为protoc的程序编译协议缓冲区。这不太可能安装在您的系统上。版本 3 是 2016 年 7 月才发布的,所以存储库中的副本很可能是版本 2。

从协议缓冲区 v3.0.0 页面安装最新版本。以 64 位 Linux 为例,从 GitHub 下载protoc-3.0.0-linux-x86_64.zip并解压到合适的地方(它包括二进制bin/protoc,应该放在你的PATH的某个地方)。

安装通用二进制文件。您还需要“后端”来生成 Go 文件。为此,从 GitHub 获取它:

go get -u github.com/golang/protobuf/protoc-gen-go      

您几乎已经准备好编译一个.proto文件了。前面的例子personv3.proto声明了包person。在你的GOPATH中,你应该有一个名为src的目录。创建一个名为src/person的子目录。然后编译personv3.proto如下:

protoc --go_out=src/person personv3.proto

这应该会创建src/person/personv3.pb.go文件。

编译后的 personv3.pb.go 文件

编译后的文件将声明许多类型和这些类型上的方法。这些类型如下:

type Person struct {
        Name  *Person_Name    `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
        Email []*Person_Email `protobuf:"bytes,2,rep,name=email" json:"email,omitempty"`
}

type Person_Name struct {
        Family   string `protobuf:"bytes,1,opt,name=family" json:"family,omitempty"`
        Personal string `protobuf:"bytes,2,opt,name=personal" json:"personal,omitempty"`
}

type Person_Email struct {
        Kind    string `protobuf:"bytes,1,opt,name=kind" json:"kind,omitempty"`
        Address string `protobuf:"bytes,2,opt,name=address" json:"address,omitempty"`
}

它们在名为person的包中。(注意:字符串等简单类型直接编码。在协议缓冲区 v2 中,使用了指针。对于复合类型,需要一个指针,如在 v2 中。)

使用编译的代码

JSON 示例中使用的代码和这个示例中使用的代码基本上没有区别,除了必须监视所用结构的指针。一个简单的程序就是ProtocolBuffer.go来编组和解组一个Person:

编组前后的输出应该是一个Person,并且应该是相同的:

/* ProtocolBuffer
 */

package main

import (
        "fmt"
        "github.com/golang/protobuf/proto"
        "os"
        "person"

)

func main() {
        name := person.Person_Name{
                Family:   "newmarch",
                Personal: "jan"}

        email1 := person.Person_Email{
                Kind:    "home",
                Address: "jan@newmarch.name"}
        email2 := person.Person_Email{
                Kind:    "work",
                Address: "j.newmarch@boxhill.edu.au"}

        emails := []*person.Person_Email{&email1, &email2}
        p := person.Person{
                Name:  &name,
                Email: emails,
        }
        fmt.Println(p)

        data, err := proto.Marshal(&p)
        checkError(err)
        newP := person.Person{}
        err = proto.Unmarshal(data, &newP)
        checkError(err)
        fmt.Println(newP)
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

编组前后的输出应该是一个人,通过运行以下命令应该是相同的:

   go run ProtocolBuffer.go

{family:"newmarch" personal:"jan"  [kind:"home" address:"jan@newmarch.name"  kind:"work" address:"j.newmarch@boxhill.edu.au" ]}
{family:"newmarch" personal:"jan"  [kind:"home" address:"jan@newmarch.name"  kind:"work" address:"j.newmarch@boxhill.edu.au" ]}

我们还没有对编组对象做太多的工作。然而,它可以被保存到文件中或通过网络发送,并由任何支持的语言解组:C++、C#、Java、Python 以及 Go。

结论

本章讨论了序列化数据类型的一般属性,并展示了一些常见的编码。还有很多,包括 XML(包含在 Go 库中)、CBOR(JSON 的二进制形式)和 YAML(类似于 XML),以及许多特定于语言的语言,如 Java 对象序列化和 Python 的 Pickle。那些不在 Go 标准包中的可能经常在 GitHub 上找到。

Footnotes 1

我将序列化和编组视为同义词。关于这一点有各种各样的观点,有些观点比其他观点更具体。例如,参见“序列化和封送处理的区别是什么?

五、应用层协议

客户端和服务器需要通过消息交换信息。TCP 和 UDP 提供了实现这一点的传输机制。这两个过程还需要有一个合适的协议,这样消息交换才能有意义地进行。协议通过指定消息、数据类型、编码格式等,定义了分布式应用程序的两个组件之间可以进行何种类型的对话。本章着眼于这个过程中涉及的一些问题,并给出一个简单的客户机-服务器应用程序的完整例子。

协议设计

在设计协议时,有许多可能性和问题需要决定。一些问题包括:

  • 是广播还是点对点?广播可以是 UDP、本地多播或更具实验性的 MBONE。点对点可以是 TCP 或 UDP。
  • 是有状态还是无状态?一方维护另一方的状态合理吗?一方维护另一方的状态通常更简单,但是如果某个东西崩溃了会发生什么呢?
  • 传输协议可靠还是不可靠?可靠的往往比较慢,但是这样你就不用那么担心丢失消息了。
  • 需要回复吗?如果需要回复,如何处理丢失的回复?可以使用超时。
  • 您想要什么数据格式?上一章讨论了几种可能性。
  • 你的交流是突发性的还是源源不断的?以太网和互联网最擅长突发流量。视频流尤其是语音流需要稳定的流。如果需要,您如何管理服务质量(QoS)?
  • 是否有多个流需要同步?数据是否需要与任何东西同步,比如视频和语音?
  • 您是在构建一个独立的应用程序还是一个供他人使用的库?所需文件的标准可能会有所不同。

你为什么要担心?

据报道,亚马逊首席执行官杰夫·贝索斯在 2002 年发表了以下声明:

  • 所有团队将从此通过服务接口公开他们的数据和功能。
  • 团队必须通过这些接口相互交流。
  • 不允许其他形式的进程间通信:不允许直接链接,不允许直接读取另一个团队的数据存储,不允许共享内存模型,不允许任何后门。唯一允许的通信是通过网络上的服务接口调用。
  • 他们用什么技术并不重要。HTTP、Corba、Pubsub、自定义协议——都没关系。贝佐斯不在乎。
  • 所有的服务接口,无一例外,都必须从头开始被设计成可外部化的。也就是说,团队必须计划和设计能够向外部世界的开发人员公开接口。没有例外。
  • 不这么做的人都会被开除。

(来源:Rip Rowan 关于 Steve Yegge 在 https://plus.google.com/+RipRowan/posts/eVeouesvaVX 发帖的博客)。)贝佐斯所做的是围绕服务架构引导世界上最成功的互联网公司之一,接口必须足够清晰,所有的通信都必须通过这些接口进行,不能有混乱或错误。

版本控制

客户机-服务器系统中使用的协议会随着时间的推移而发展,随着系统的扩展而变化。这就产生了兼容性问题:第 2 版客户机将发出第 1 版服务器无法理解的请求,而第 2 版服务器将发送第 1 版客户机无法理解的回复。

理想情况下,每一方都应该能够理解来自自己版本和所有早期版本的消息。它应该能够以旧式响应格式编写对旧式查询的回复。见图 5-1 。

A436770_1_En_5_Fig1_HTML.gif

图 5-1。

Compatibility versus version control

如果协议变化太大,可能会失去与早期版本格式对话的能力。在这种情况下,您需要能够确保不存在早期版本的副本,这通常是不可能的。

协议设置的一部分应该包括版本信息。

网络

网络是一个系统经历多个不同版本的好例子。底层 HTTP 协议以出色的方式管理版本控制,即使它已经经历了四个版本。大多数服务器/浏览器支持最新版本,但也支持早期版本。截至 2017 年 1 月,最新版本的 HTTP/2 似乎占网络流量的 11%多一点,而 HTTP/1.1 几乎占了其余的所有份额。每个请求中都给出了版本,如以下GET请求所示:

| 请求 | 版本 | | --- | --- | | `GET /` | 1.0 之前 | | `GET / HTTP/1.0` | HTTP 1.0 | | `GET / HTTP/1.1` | HTTP 1.1 | | `GET / HTTP/1.1`连接:升级,http 2-设置升级:h2c | HTTP 2 |

HTTP/2 是二进制格式,与早期版本不兼容。然而,有一种协商机制可以发送一个带有 HTTP/2 升级字段的 HTTP/1.1 请求。如果客户端接受,就可以进行升级。如果客户端不理解升级参数,连接将继续使用 HTTP/1.1。

虽然 HTTP 最初是为 HTML 设计的,但它可以承载任何内容。如果我们只看 HTML,它已经经历了大量的版本,有时很少尝试确保版本之间的兼容性:

  • HTML5,它放弃了点修订之间的任何版本信令
  • HTML 版本 1-4(各不相同),其中“浏览器大战”中的版本尤其成问题
  • 不同浏览器识别的非标准标签
  • 非 HTML 文档通常需要可能不存在的内容处理程序;你的浏览器有 Flash 的处理程序吗?
  • 对文档内容的处理不一致(例如,一些样式表内容会使一些浏览器崩溃)
  • 对 JavaScript 的不同支持(以及不同版本的 JavaScript)
  • 不同的 Java 运行时引擎
  • 许多页面不符合任何 HTML 版本(例如,有语法错误)

HTML5(实际上还有许多早期版本)是一个不做版本控制的极好例子。撰写本文时的最新修订版是修订版 5。在此版本中,引入了新功能来帮助 Web 应用程序作者,引入了基于对流行创作实践的研究的新元素。不仅添加了一些新功能,而且一些旧功能(应该不会再使用了)也被删除,不再工作。HTML5 文档没有办法表明它使用的是哪个版本。

消息格式

在上一章中,我们讨论了表示通过网络发送的数据的一些可能性。现在我们向上看一层,看可能包含这种数据的消息。

  • 客户端和服务器将交换具有不同含义的消息:
    • 登录请求
    • 登录回复
    • 获取记录请求
    • 记录数据回复
  • 客户端将准备一个请求,这个请求必须被服务器理解。
  • 服务器将准备一个必须被客户端理解的回复。

通常,消息的第一部分是消息类型。

  • 客户端到服务器:

    LOGIN <name> <passwd>
    GET <subject> grade
    
    
  • 服务器到客户端:

    LOGIN succeeded
    GRADE <subject> <grade>
    
    

消息类型可以是字符串或整数。比如 HTTP 用 404 这样的整数表示“没有找到”(虽然这些整数都写成字符串)。从客户端到服务器的消息是不相交的,反之亦然。从客户端到服务器的LOGIN消息与从服务器到客户端的LOGIN消息是不同的消息,并且它们可能在协议中起补充作用。

数据格式

消息有两种主要的格式选择:字节编码或字符编码。

字节格式

在字节格式中:

  • 消息的第一部分通常是一个字节,用于区分消息类型。
  • 消息处理程序检查第一个字节以区分消息类型,然后执行切换以选择适合该类型的处理程序。
  • 消息中的其他字节包含符合预定义格式的消息内容(如前一章所述)。

优点是紧凑,因此速度快。缺点是由数据的不透明性引起的:可能更难发现错误,更难调试,并且需要特殊目的的解码功能。有许多字节编码格式的例子,包括 DNS 和 NFS 等主要协议,以及 Skype 等最新协议。当然,如果您的协议没有公开指定,那么字节格式也会使其他人更难对其进行逆向工程!

字节格式服务器的伪代码如下:

handleClient(conn) {
    while (true) {
        byte b = conn.readByte()
        switch (b) {
            case MSG_1: ...
            case MSG_2: ...
            ...
        }
    }
}

Go 对管理字节流有基本的支持。接口io.ReaderWriter有这些方法:

Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)

这些方法由 TCPConn 和 UDPConn 实现。

字符格式

在这种模式下,所有内容都尽可能以字符形式发送。例如,整数 234 将作为,比如说,三个字符234发送,而不是作为一个字节 234 发送。固有的二进制数据可以进行 Base64 编码,将其转换为 7 位格式,然后作为 ASCII 字符发送,如前一章所述。

以字符格式:

  • 消息是一行或多行的序列。消息第一行的开头通常是代表消息类型的单词。
  • 字符串处理函数可用于解码消息类型和数据。
  • 第一行和后续行的剩余部分包含数据。
  • 面向行的函数和面向行的约定用于管理这一点。

伪代码如下:

handleClient() {
    line = conn.readLine()
    if (line.startsWith(...) {
        ...
    } else if (line.startsWith(...) {
        ...
    }
}

字符格式更容易设置和调试。例如,您可以使用telnet在任何端口上连接到服务器,并向该服务器发送客户端请求。没有像telnet这样简单的工具向客户端发送服务器响应,但是您可以使用像tcpdumpwireshark这样的工具来窥探 TCP 流量,并立即查看客户端向服务器发送了什么,以及从服务器接收了什么。

在 Go 中对管理字符流没有相同级别的支持。字符集和字符编码有一些重要的问题,我们将在后面的章节中探讨这些问题。

如果我们只是假设一切都是 ASCII,就像从前一样,那么字符格式就很容易处理。这一级的主要复杂性是不同操作系统中“换行符”的不同状态。UNIX 使用单个字符\n。Windows 和其他(更正确地说)使用对\r\n。在互联网上,这一对\r\n最为常见。UNIX 系统只需要注意不要假设\n

简单的例子

这个例子处理一个目录浏览协议,它基本上是 FTP 的一个简化版本,但是甚至没有文件传输部分。我们只考虑列出一个目录名,列出一个目录的内容,并更改当前目录——当然,所有这些都在服务器端。这是一个创建客户机-服务器应用程序所有组件的完整实例。这是一个简单的程序,包括双向消息,以及消息协议的设计。

独立的应用程序

看一个简单的非客户机-服务器程序,它允许您列出目录中的文件,并更改和打印服务器上的目录名。我们省略了复制文件,因为这增加了程序的长度,却没有引入重要的概念。为简单起见,所有文件名都假定为 7 位 ASCII 码。如果我们先看一个独立的应用程序,它将如图 5-2 所示。

A436770_1_En_5_Fig2_HTML.gif

图 5-2。

The standalone application

伪代码如下所示:

read line from user
while not eof do
  if line == dir
    list directory // local function call
  else

  if line == cd <directory>
    change directory // local function call
  else

  if line == pwd
    print directory // local function call
  else

  if line == quit
    quit
  else
    complain

  read line from user

非分布式应用程序将通过本地函数调用简单地链接 UI 和文件访问代码。

客户端-服务器应用程序

在客户机-服务器的情况下,客户机位于用户端,与其他地方的服务器通信。这个程序的某些方面只属于表示端,比如从用户那里获取命令。一些是从客户端到服务器的消息;有些只是在服务器端。见图 5-3

A436770_1_En_5_Fig3_HTML.gif

图 5-3。

The client-server situation

客户端

对于一个简单的目录浏览器,假设所有的目录和文件都在服务器端,我们只将文件信息从服务器传输到客户机。客户端(包括表示方面)将成为:

read line from user
while not eof do
  if line == dir
    list directory // network call to server

  else

  if line == cd <directory>
    change directory // network call to server

  else

  if line == pwd
    print directory // network call to server

  else

  if line == quit
    quit
  else
    complain

  read line from user

其中调用list directorychange directoryprint directory现在都涉及到对服务器的网络调用。细节尚未显示,将在稍后讨论。

可选演示方面

GUI 程序将允许目录内容显示为列表,以便选择文件和对它们执行诸如更改目录之类的操作。客户端将由与图形对象上发生的各种事件相关联的动作来控制。伪代码可能如下所示:

change dir button:
  if there is a selected file
    change directory // remote call to server

  if successful
    update directory label
    list directory // remote call to server

    update directory list

从不同 ui 调用的函数应该是相同的——改变表示不应该改变网络代码。

服务器端

服务器端独立于客户端使用的任何表示。所有客户端都是一样的:

while read command from client
  if command == dir
    send list directory // local call on server

  else

  if command == cd <directory>
    change directory // local call on server

  else

  if command == pwd
    send print directory // local call on server

  else

礼仪:非正式

| 客户请求 | 服务器响应 | | --- | --- | | `dir` | 发送文件列表 | | `cd ` | 更改`dir`失败时发送错误,成功时发送`ok` | | `pwd` | 发送当前目录 | | `quit` | 放弃 |

文本协议

这是一个简单的协议。我们需要发送的最复杂的数据结构是一个目录列表的字符串数组。在这种情况下,我们不需要上一章的重载序列化技术。在这种情况下,我们可以使用简单的文本格式。

但是即使我们使协议变得简单,我们仍然必须详细地指定它。我们选择以下消息格式:

  • 所有消息都是 7 位 US-ASCII 格式。
  • 这些消息区分大小写。
  • 每条消息由一系列行组成。
  • 每条消息第一行的第一个词描述了消息类型。所有其他字都是消息数据。
  • 所有单词都由一个空格分隔。
  • 每一行都以 CR-LF 结尾。

上面所做的一些选择在现实生活的协议中比较弱。例如:

  • 消息类型可以不区分大小写。这只需要在解码前将消息类型字符串映射为小写。
  • 单词之间可以留任意数量的空白。这只是增加了一点复杂性,压缩空白。
  • \这样的连续字符可以用来在几行上断开长行。这开始使处理变得更加复杂。
  • 仅仅一个\n就可以作为行终止符,\r\n也可以。这使得识别行尾有点困难。

所有这些变化都存在于真实的协议中。累积起来,它们使得字符串处理比这种情况更复杂。

| 客户请求 | 服务器响应 | | --- | --- | | `send "DIR"` | 发送文件列表,每行一个,以空行结束 | | `send "CD "` | 更改`dir`如果失败发送`"ERROR"`如果成功发送`"OK"` | | `send "PWD"` | 发送当前工作目录 |

我们还应该指定运输工具:

  • 所有消息都通过从客户端到服务器建立的 TCP 连接发送。

服务器代码

服务器是FTPServer.go:

/* FTP Server
 */
package main

import (
        "fmt"
        "net"
        "os"
)

const (
        DIR = "DIR"
        CD  = "CD"
        PWD = "PWD"
)

func main() {

        service := "0.0.0.0:1202"
        tcpAddr, err := net.ResolveTCPAddr("tcp", service)
        checkError(err)

        listener, err := net.ListenTCP("tcp", tcpAddr)
        checkError(err)

        for {
                conn, err := listener.Accept()
                if err != nil {
                        continue
                }
                go handleClient(conn)

        }
}

func handleClient(conn net.Conn) {
        defer conn.Close()

        var buf [512]byte
        for {
                n, err := conn.Read(buf[0:])
                if err != nil {
                        conn.Close()
                        return
                }

                s := string(buf[0:n])
                // decode request
                if s[0:2] == CD {
                        chdir(conn, s[3:])
                } else if s[0:3] == DIR {
                        dirList(conn)
                } else if s[0:3] == PWD {
                        pwd(conn)
                }

        }
}

func chdir(conn net.Conn, s string) {
        if os.Chdir(s) == nil {
                conn.Write([]byte("OK"))
        } else {
                conn.Write([]byte("ERROR"))
        }
}

func pwd(conn net.Conn) {
        s, err := os.Getwd()
        if err != nil {
                conn.Write([]byte(""))
                return
        }
        conn.Write([]byte(s))
}

func dirList(conn net.Conn) {
        // send a blank line on termination
        defer conn.Write([]byte("\r\n"))

        dir, err := os.Open(".")
        if err != nil {
                return
        }

        names, err := dir.Readdirnames(-1)

        if err != nil {
                return
        }
        for _, nm := range names {
                conn.Write([]byte(nm + "\r\n"))
        }
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

客户代码

命令行客户端是FTPClient.go:

/* FTPClient
 */
package main

import (
        "bufio"
        "bytes"
        "fmt"
        "net"
        "os"
        "strings"
)

// strings used by the user interface
const (
        uiDir  = "dir"
        uiCd   = "cd"
        uiPwd  = "pwd"
        uiQuit = "quit"
)

// strings used across the network
const (
        DIR = "DIR"
        CD  = "CD"
        PWD = "PWD"
)

func main() {
        if len(os.Args) != 2 {

                fmt.Println("Usage: ", os.Args[0], "host")
                os.Exit(1)
        }

        host := os.Args[1]

        conn, err := net.Dial("tcp", host+":1202")
        checkError(err)

        reader := bufio.NewReader(os.Stdin)
        for {
                line, err := reader.ReadString('\n')
                // lose trailing whitespace
                line = strings.TrimRight(line, " \t\r\n")
                if err != nil {
                        break
                }

                // split into command + arg
                strs := strings.SplitN(line, " ", 2)
                // decode user request
                switch strs[0] {
                case uiDir:
                        dirRequest(conn)
                case uiCd:
                        if len(strs) != 2 {
                                fmt.Println("cd <dir>")
                                continue
                        }
                        fmt.Println("CD \"", strs[1], "\"")
                        cdRequest(conn, strs[1])
                case uiPwd:
                        pwdRequest(conn)
                case uiQuit:
                        conn.Close()
                        os.Exit(0)
                default:
                        fmt.Println("Unknown command")
                }
        }
}

func dirRequest(conn net.Conn) {
        conn.Write([]byte(DIR + " "))

        var buf [512]byte
        result := bytes.NewBuffer(nil)
        for {
                // read till we hit a blank line
                n, _ := conn.Read(buf[0:])
                result.Write(buf[0:n])
                length := result.Len()
                contents := result.Bytes()
                if string(contents[length-4:]) == "\r\n\r\n" {
                        fmt.Println(string(contents[0 : length-4]))
                        return
                }
        }
}

func cdRequest(conn net.Conn, dir string) {
        conn.Write([]byte(CD + " " + dir))
        var response [512]byte
        n, _ := conn.Read(response[0:])
        s := string(response[0:n])
        if s != "OK" {
                fmt.Println("Failed to change dir")
        }
}

func pwdRequest(conn net.Conn) {
        conn.Write([]byte(PWD))
        var response [512]byte
        n, _ := conn.Read(response[0:])
        s := string(response[0:n])
        fmt.Println("Current dir \"" + s + "\"")
}

func checkError(err error) {
        if err != nil {
                fmt.Println("Fatal error ", err.Error())
                os.Exit(1)
        }
}

文本换行软件包

textproto包包含的功能旨在简化类似于 HTTP 和 SNMP 的文本协议的管理。

这些格式对于在多行上延续的单个逻辑行有一些鲜为人知的规则,例如:“如果延续行以空格或水平制表符开始,则 HTTP/1.1 头字段值可以折叠到多行上”(HTTP1.1 规范)。允许像这样的行的格式可以使用ReadContinuedLine()函数读取,除了像ReadLine()这样更简单的函数。

这些协议还通过以三位数代码开头的行(如404)来表示状态值。这些可以使用ReadCodeLine()读取。他们也有关键的价值线,如Content-Type: image/gif。这样的线可以通过ReadMIMEHeader()读入一张地图。

状态信息

应用程序通常使用状态信息来简化正在发生的事情。例如:

  • 保持指向当前文件位置的文件指针。
  • 保持当前鼠标位置。
  • 保持当前的客户价值。

在分布式系统中,这种状态信息可以保存在客户机、服务器或两者中。

重要的一点是,一个进程保存的状态信息是关于它自己的还是关于另一个进程的。一个进程可以根据自己的需要保存尽可能多的状态信息,而不会引起任何问题。如果它需要保存另一个进程的状态信息,那么问题就出现了。该进程对另一个进程的状态的实际了解可能变得不正确。这可能是由于消息丢失(在 UDP 中)、更新失败或软件错误造成的。

读取文件就是一个例子。在单进程应用程序中,文件处理代码作为应用程序的一部分运行。它维护一个打开文件的表以及每个文件的位置。每次读取或写入完成时,该文件位置都会更新。在分布式系统中,这个简单的模型不成立。见图 5-4 。

A436770_1_En_5_Fig4_HTML.gif

图 5-4。

The DCE file system

在图 5-4 所示的 DCE 文件系统中,文件服务器跟踪客户端打开的文件以及客户端文件指针的位置。如果消息可能丢失(但 DCE 使用 TCP),这些可能会失去同步。如果客户机崩溃,服务器最终必须在客户机的文件表上超时并删除它们。

在 NFS,服务器不保持这种状态。客户知道。从客户端到达服务器的每个文件访问必须在客户端给出的适当点打开文件,以便执行操作。见图 5-5 。

A436770_1_En_5_Fig5_HTML.gif

图 5-5。

The NFS file system

如果服务器维护有关客户机的信息,那么它必须能够在客户机崩溃时恢复。如果信息没有被保存,那么在每一次交易中,客户端必须传输足够的信息以使服务器正常工作。

如果连接不可靠,必须进行额外的处理,以确保两者不会失去同步。典型的例子是银行账户交易,其中消息丢失。交易服务器可能需要成为客户机-服务器系统的一部分。

应用程序状态转换图

状态转换图跟踪应用程序的当前状态以及使其进入新状态的更改。

前面的例子基本上只有一种状态:文件传输。如果我们添加一个登录机制,这将增加一个额外的状态,称为登录,应用程序将需要在登录和文件传输之间改变状态,如图 5-6 所示。

A436770_1_En_5_Fig6_HTML.gif

图 5-6。

The state-transition diagram

这种状态变化也可以表示为表格:

| 初速电流状态 | 过渡 | 次状态 | | --- | --- | --- | | `login` | `login failed` | `login` | | `login succeeded` | `file transfer` | | `file transfer` | `dir` | `file transfer` | | `get` | `file transfer` | | `logout` | `login` | | `quit` | `-` |

客户端状态转换图

客户端状态图必须跟在应用程序图后面。不过它有更多的细节:它先写,然后读:

| 初速电流状态 | 写 | 阅读 | 次状态 | | --- | --- | --- | --- | | `login` | `LOGIN name password` | `FAILED` | `login` | | `OK` | `file transfer` | | `file transfer` | `CD dir` | `OK` | `file transfer` | | `FAILED` | `file transfer` | | `GET filename` | `#lines + contents` | `file transfer` | | `FAILED` | `file transfer` | | `DIR` | `File names + blank line` | `file transfer` | | `blank line (Error)` | `file transfer` | | `quit` | `none` | `quit` |

服务器状态转换图

服务器状态图也必须跟在应用程序图后面。它还有更多细节:它先读,然后写:

| 初速电流状态 | 阅读 | 写 | 次状态 | | --- | --- | --- | --- | | `login` | `LOGIN name password` | `FAILED` | `login` | | `OK` | `file transfer` | | `file transfer` | `CD dir` | `SUCCEEDED` | `file transfer` | | `FAILED` | `file transfer` | | `GET filename` | `#lines + contents` | `file transfer` | | `FAILED` | `file transfer` | | `DIR` | `filenames + blank line` | `file transfer` | | `blank line (failed)` | `file transfer` | | `quit` | `none` | `login` |

服务器伪代码

以下是服务器伪代码:

state = login
while true
    read line
    switch (state)
        case login:
            get NAME from line
            get PASSWORD from line
            if NAME and PASSWORD verified
                write SUCCEEDED
                state = file_transfer
            else
                write FAILED
                state = login
        case file_transfer:
            if line.startsWith CD
                get DIR from line
                if chdir DIR okay
                    write SUCCEEDED
                    state = file_transfer

                else
                    write FAILED
                    state = file_transfer
            ...

我们没有给出这个服务器或客户端的实际代码,因为它非常简单。

结论

构建任何应用程序都需要在开始编写代码之前做出设计决策。与独立系统相比,使用分布式应用程序,您可以做出更大范围的决策。本章考虑了其中的一些方面,并演示了最终的代码可能是什么样子。我们只触及了协议设计的元素。有许多正式和非正式的模型。IETF(互联网工程任务组)在其 RFC(请求注解)中为其协议规范创建了标准格式,迟早,每个网络工程师都需要与 RFC 一起工作。