Application-Level Protocols
一个客户端和服务器交换消息包含消息类型和消息数据。这需要设计合适的消息交换协议。这一章主要看看这方面的问题,并且给出一个完整的client-server应用程序。
Introduction
客户端和服务器需要通过message来交换信息。TCP和UDP提供了传输的机制来做这个事情。一个协义是通过规范消息,数据类型和编码格式等来定义分部式应用程序两个组件之间采取的数据类型。
Protocal Design
当设计协议时,需要决定以下的问题
- 广播还是端到端? 如果是广播的,则必须是UDP, local multicast or Multicast BackBone. 点到点的可以是TCP或者UDP.
- 有状态还是无状态?
- 传输协议是可靠还是非可靠的? 可靠的话会通常较慢,但可以不用担心丢失消息。
- 是否需要返回? 如果需要返回,那就要考虑处理没有返回的情况。一般使用超时
- 你想要传输什么数据格式?通常为MIME或者字节编码
- 通信是突发性的,还是稳定的? Ethernet and Internet最好是bursty traffic. 视频播放使用Steady stream. 如果需要,还要考虑如何管理Quality of Service (Qos)?
- 是否需要多个stream同步? 是否需要有数据跟其它的数据同步传输? e.g. video and voice
Version Control
一个协议的变化会随着时间的变化而变化。这就增了兼容性的问题,一个version2的客户端向version1的服务器发送请求,version1服务器可能不会明名这是什么。 反之,version2的服务器向version1的客户端返回,version1也不能明白是什么。
在两端最好都能理解它自己和之前的协议。它应该可以给旧的查询样式发送旧的响应格式
The web
Web是最好的多版本的例子,这个协议可以跨三个版本,最主要的服务器和浏览器都是使用最新的版本。
request | version |
---|---|
GET / | pre 1.0 |
GET / HTTP/1.0 | HTTP 1.0 |
GET / HTTP/1.1 | HTTP 1.1 |
- HTML version有1-4,最新的为5
- 非标准的标签可以被不同的浏览器识别
- 非non-HTML文档通常需要内容处理器-比如flash
- 不一致的文档内容的处理(e.g.有的stylesheet内容会导致浏览器崩溃)
- 支持不同的Javascript版本
- 支持不同的Java运行时引擎
- 许多页面的容不符合任何HTML(e.g. 语法错误)
消息格式
一个消息可以包含以下的数据.
客户端和服务器交换的信息含有不同的含义. e.g.
- Login request
- get record request
- login reply
- record data reply
客户端准备发起的请求必须是服务器可以理解的
- 服务器准备返回的数据,必须是客户端可以理解的
通常,消息的第一部分如下
- Client to server
LOGIN name passwd
Get cpe1000 grade
- Server to client
LOGIN succeeded
GRADE cpe1000 D
消息的类型可以是字符串或者整型。比如HTTP使用整型,比如 404 "not found"(虽然这些整型会被写成字符串)。以下这个例子中,客户端发送的"LOGIN"和服务器发送的"LOGIN"是有不同的含义。
Data Format
消息的格式有两种选择:字节编码和字符编码
Byte format
在字节格式中
- 消息的第一部分通常用来区分消息的类型
- 消息的处理者将会检查第一个字切,用来区分消息的类型,然后选择跟此类型相匹配的消息处理器进行处理
- 消息中的之后的字节会包含消息的内容,这些消息内容依照之前定义的格式编码而成(我们序列化那一章讲到过)
- 字节的优点是简洁,速度快。缺点是数据的不透明: 难以发现错误,调试,需要专门的解码函数。这里有很多的例子是使用byte-encoded格式,包括很多重要的协议,比如DNS和NFS,以及Skype. 当然,如果你的协议不是用于公开的,字节格式的编码让其它人难以逆向。
以下是byte-format 的服务器的伪代码
handleClient(conn) {
while (true) {
byte b = conn.readByte()
switch (b) {
case MSG_1: ...
case MSG_2: ...
...
}
}
}
Go支持基础的字节流管理。接口Conn有以下的方法
(c Conn) Read(b []byte) (n int, err os.Error)
(c Conn) Write(b []byte) (n int, err os.Error)
TCPConn和UDPConn都实现了这两个方法
Character Format
在这种模式下,一切都是以字符发送。比如,一个整型234,将以3个字符'2','3','4'代替,而不是一个字节234。数据内部的二进制使用base64编码,将二进制转变为7-bit格式,然后发送ASCII字符,如我们在上一章中讨论的那样。
以下是字符格式
- 一个消息是一个或者多行的序列: 第一行的开始通常是一个word, 表示消息的类型
- 字符串处理函数可以用于解码数据类型和数据
- 第一行后面的类容和接连的行都是数据内容
- 面向行的函数和面向函数的转换被用于管理这个
伪代码如下
handleClient() {
line = conn.readLine()
if (line.startsWith(...) {
...
} else if (line.startsWith(...) {
...
}
}
字符格式易于设置和调试。比如,你可以使用telnet来连接服务器的任意端口,然后发送客户端的请求给到服务器。这也不是那么容易,你可以使用tcpdump来窥探TCP传输的内容,这样就能知道客户端正在给服务器在发送什么。
Go对于字符流的管理,不像对字节流那样的支持Read(b []byte) and Write(b []byte). 字符流的管理涉及到字符集和编码的问题,我们将在下一章中解释这些问题。
如果假设字符流传输的都是ASCII,所有的问题就变得简单了。但还有一个问题是不同的操作系统对换行符不同处理。Unix使用单个的'\n', Windows使用"\r\n". 在互联网中,"\r\n"更常见一点。所以在Unix系统上要注意这一点。
Simple Example
这个例子是用来处理目录浏览协议——基本上是FTP的精简版,只是没有文件传输部分。我们仅需要考虑列出目录名字,列出目录的内容和改变当前的目录。这是一个完整的client-server application. 这是一个简单的程序,它包含了双向的消息以及设计消息协议。
让我们看一个简单的非client-server程序,它允许你列出目录中的文件,改变当前目录,并且打印出目录。我们省略了复制文件,因为这只会增加程序源文件的长度,但并没有涉及重要的概念。为了简单,所以有文件名假设都为7-bit ASCII.如果我们仅仅是独立的应用程序,伪代码为
read line from user
while not eof do
if line == dir
list directory
else
if line == cd <dir>
change directory
else
if line == pwd
print directory
else
if line == quit
quit
else
complain
read line from user
一个非分布式的应用程序只是连接UI和文件访问码
在客户端-服务器的情况,client在用户那一端,而服务器在其它地方。
对于一个简单的目录浏览器,假设所有的目录和文件在服务器端,我们仅仅是从服务器将向客户端传送文件信息。客户端代码如下
read line from user
while not eof do
if line == dir
list directory //与服务器通信
else
if line == cd <dir>
change directory
else
if line == pwd
print directory
else
if line == quit
quit
else
complain
read line from user
替代的表示形式
一个GUI程序允许目录内容以列表的形式显示,选择多个文件,以及其它操作,比如改变目录。客户端可以通过图形对像改变它的行为。伪代码如下
change dir button:
if there is a selected file
change directory
if successful
update directory label
list directory
update directory list
从不同的UI中调用的函数应该是相同的-改变界面,不能改变网络代码
非正式的协议
client request | server response |
---|---|
dir | change dir, send error if failed, send ok if succeed |
pwd | send current directory |
quit | quit |
Text protocal
这是一个简单的协议,最复杂的也就是发送一个字符串数组(目录列表)。在这种情况下,我们不需要使用到最后一章的技术。我们只是使用简单的文本格式的协议。
虽然协议简单,我们依然需要指定细节,我使用以下的消息格式
- 所有的信息使用7-bit US-ASCII
- 消息是区分大小写的
- 每一个消息由多行组成
- 每一个消息的第一行第一个单词,表示的是消息类型。其它的是消息数据。
- 所有的单词之间以一个空格分隔
- 每一行通过CR-LF终止
上面列出的项,在实际协议中会弱一点, 比如
- message的类型是不区分大小写的,这就需要我们在使用时转变为小写字符
- 单词之间可以是任意数量的空白符。这就增加了一些复杂度,需要我们压缩空白符
- 连续字符,比如'\'可以将长行分为多行。这使得处理起来更加复杂
- 行的终止符可以使用'\n'和'\r\n'.这使得辨认一行的结束变得复杂
所有的这此差异都存在于实际的协议中。它使得对字符串的处理要比我们的案例中的协议要复杂
client request | server response |
---|---|
send "DIR" | send list of files, one per line terminated by a blank line |
send "CD |
change dir, send "ERROR" if failed, send "OK" |
send "PWD" | sent current working directory |
Server code
package main
import (
"fmt"
"net"
"os"
)
const (
DIR = "DIR"
CD = "CD"
PWD = "PWD"
)
func main() {
service := ":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(""))
} else {
conn.Write([]byte(s))
}
}
func dirList(conn net.Conn) {
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.Fprintf(os.Stderr, "Fatal error:%s", err.Error())
os.Exit(1)
}
}
Client Code
package main
import (
"bufio"
"bytes"
"fmt"
"net"
"os"
"strings"
)
const (
uiDir = "dir"
uiCd = "cd"
uiPwd = "pwd"
uiQuit = "quit"
)
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')
line = strings.TrimRight(line, "\t\r\n")
if err != nil {
break
}
strs := strings.SplitN(line, " ", 2)
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("Unknow command")
}
}
}
func dirRequest(conn net.Conn) {
conn.Write([]byte(DIR + " "))
var buf [512]byte
result := bytes.NewBuffer(nil)
for {
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.Fprintf(os.Stderr, "Fatal error:%s", err.Error())
os.Exit(1)
}
}
State
应用程序经常应用state information,来表明下一步要做的事情。比如
- 保存当前的文件指针和当前的文件位置
- 保存当前的鼠标位置
- 保存当前的客户值
在一个分布式系统中,这些信息都是保存在客户端,或者服务器中,或者两者都有
一个重要的一点是,一个进程保存的状态是只给自己还是其它的也可以处理。如果是自己的话,可以是任意数量的状态,而不会引发任何问题。如果跟其它的进程一起的话,就增加了复杂度。保存的状态值,可能在其它进程中已经被改变了。这可以会引消息的丢失(UDP中),更新失败或者s/w error.
举一个读取文件的例子。在单进程的应用程序中,文件处理代码作为应用程序的一部分在运行。它维持一个打开文件的内存表和每个文件位置。每一次对文件读和写都会更新这张表。在DCE文件系统中,文件服务器跟踪客户打开的文件,以及客户的文件指针。如果一个消息丢失(但DCE是使用的TCP,所以不会丢失数据包), 服务器可以不跟客户端同步. 如果客户端发生崩溃,服务器必须让客户端文件表超时,并删除文件表。
在NFS中,服务器不会维护这个状态。而是在客户端。从客户端发起的文件访问,到达服务器后必须在打开文件中的合适位置,同时由客户端执行实际动作。
如果是在服务器上维护客户端信息,它必须在客户端发生崩溃后可以恢复。如果信息没有被保存,则每一次与客户端事务都必须向服务器传递足够的信息。
如果连接是不可靠的,在合适的地方,必须有额外的处理机制来保证两端的同步。最经典的例子是银行帐号的交易消息丢失。交易服务器会是client-sever系统的一部分。
Application State Transition Diagram
状态转移图用来保持追踪当前应用程序的状态和到一个新状态的改变。
举例来说:登录文件传送服务器
它可以表示成以下的形式
Client state transition diagrams
客户端状态转移图表如下所示。它有更多的细节:它先写然后在读
服务器状态转换图
服务器状态转换图如下所示,它先读后写
服务器的伪代码为
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
...
我们在这里没有给出实际的代码,是因为这已经相当的明了了
Summary
建立任何的应用,在写代码之前都需要先设计决策好,对于分布式的应用,它要比独立的应用程序要决策的地方更多。本章考虑了其中的一些方面,并且演示了一些代码。