客户端和服务器的交互包括消息类型
和消息数据
,这就需要有适当的交互协议。本章着重讨论客户端和服务器交互相关的问题,并给出一个完整又简单的客户端服务器交互的例子。
介绍
客户端和服务器需要通过消息来进行交互。TCP
和 UDP
是信息交互的两种传输机制。在这两种传输机制之上就需要有协议来约定传输内容的含义。协议清楚说明分布式应用的两个模块之间交互消息的消息体、消息的数据类型、编码格式等。
协议设计
当设计协议的时候,有许多许多的情况和问题需要考虑,比如:
- 是广播还是单播?
广播必须使用 UDP,本地组播或者是更成熟的组播骨干网(MBONE)。单播就可
以使用 TCP 或者 UDP。 - 消息应该是有状态还是无状态的?
任意一边是否有必要维持另外一边的状态消息?虽然这个似乎很好实现,但是如果
发生异常的时候又应该怎么做呢? - 协议是可靠服务还是不可靠服务?
一般来说,提供可靠服务就会更慢些,但好处是不需要考虑丢失消息的情况。 - 是否需要响应?
如果是需要响应的话,如何处理没有响应回复的情况?或许可以设置超时时间。 - 你想要使用什么数据格式?
一般使用两种格式:MIME 和字节流 - 消息流是使用突发性的还是稳定性的?
Ethernet 和 Internet 最好使用突发性消息流。稳定性的消息流通常是用在视频和声频
流传输上。如果要求的话,你如何保证你的服务质量(QoS)? - 有多流同步的需求吗?
是否有多种数据流需要进行同步?比如视频流和声频流。 - 建立的是单独的应用还是需要给别人使用的库?
可能需要花很大的精力编写标准文档。
版本控制
随着时间变化和系统的升级,客户端/服务器之间的协议也会升级。这可能会引起兼容性的问题:版本 2 的客户端发出的请求可能版本 1 的服务器无法解析,反之也一样,版本 2 的服务器回复的消息版本 1 的客户端无法解析
理想情况下,不论是哪一端,都应该既能满足自己当前版本的消息规范,也能满足早期版本的消息规范。任意一端对于旧版本的请求应该返回旧版本的响应。
但是如果协议变化太大的话,可能就很难保持与早期版本的兼容了。在这种情况下,你就需要保证已经不存在早期的版本了 -- 当然这个几乎是不可能的。
所以,协议应该包含有版本消息。
Web 协议
Web 协议就是一个由于有不同协议版本同时存在而出现混乱的例子。Web 协议已经有三个版本了,通常服务器和浏览器都是使用最新的版本,版本消息包含在请求中:
request | version |
---|---|
GET / | pre 1.0 |
GET / HTTP/1.0 | HTTP 1.0 |
GET / HTTP/1.1 | HTTP 1.1 |
但是消息体的内容
已经被大量版本制定修改过:
- HTML 版本 1-4(每个版本都不一样),还有即将到来的 HTML5;
- 不同浏览器各自支持非标准化的标签;
- HTML 文档之外的内容也通常需要不同的内容处理器 -- 比如你的浏览器支持 Flash
播放器吗? - 文档内容的不一致的处理方法(例如,在一些浏览器上,有的 css 会冲突)
- 浏览器对 JavaScript 的不同支持程度(当然 JavaScript 也同时存在不同的版本)
- 不同的 Java 运行引擎
- 有的页面并没有遵守任何 HTML 版本规范(比如 HTML 格式错误的页面)
消息格式
上一章我们讨论了数据传输的几种可能的表现形式。现在我们进一步研究包含数据的消息。
- 客户端和服务器会交换不同含义的消息,比如:
o 登陆请求
o 获取某些记录的请求
o 登陆请求的回复
o 获取某些记录请求的回复 - 客户端必须发送能被服务器解析的请求。
- 服务器必须回复能被客户端解析的响应。
通常来说,消息的头部必须包含消息类型。
客户端发送给服务器
LOGIN name passwd
GET cpe4001 grade
服务器返回给客户端
LOGIN succeeded
GRADE cpe4001 D
消息类型
应该设置为字符型或者整型。比如,HTTP 使用整数 404 来表示“未找到资源”(尽管这个整型是被当做字符串使用)。客户端到服务器的消息和服务器到客户端的消息是不一样的:比如从客户端到服务器的“LOGIN”消息就不同于服务器到客户端的“LOGIN”消息。
数据格式
对于消息来说,有两种主要的数据格式可供选择:字节编码
和字符编码
字节编码
对于字节编码
-
消息的头部
通常使用一个字节来标示消息的类型。 - 消息处理者应该根据消息头部的类型字节来选择合适的方法来处理这个类型的消息。
- 消息后面的字节应该是根据事先定义的格式(上一章节讨论的)来包含消息具体内
容。
字节编码的优势
就是紧凑小巧,传输速度快。劣势
就是数据的不透明性:字节编码很难定位错误,也很难调试。往往是要求写一些额外的解码函数。有许多字节编码格式的例子,大部分协议都是使用字节编码,例如 DNS 和 NFS 协议,还有最近出现的 Skype 协议。当然,如果你的协议没有公开说明结构,使用字节编码可以让其他人使用反向工程手段很难破解!
字节编码的服务器的伪代码如下
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
and UDPConn
字符编码
在这个编码模式下,所有消息都尽可能以字符的形式发送。例如,整型数字 234 会被处理成三个字符‘2’,‘3’,‘4’,而不会被处理成 234 的字节码。二进制数据将会使用 base64编码变成为 7-bit 的格式,然后当做 ASCII 码传递,就和我们上一章讨论的一样。
对于字符编码,
- 一条消息会是一行或者很多行内容
- 消息的第一行通常使用一个单词来说明消息的类型。
- 使用字符处理函数来解码消息类型和消息内容。
- 第一行之后的信息和其他行包含消息数据。
- 使用
行处理函数
和行处理规范
来处理消息。
伪代码如下
handleClient() {
line = conn.readLine()
if (line.startsWith(...) {
...
} else if (line.startsWith(...) {
...
}
}
很容易进行组装,也很容易调试。例如,你可以 telnet 连接到一台服务器的端口上,然后发送客户的请求到服务器。其他的编码方式无法轻易地监听请求。但是对于字符编码,你可以使用 tcpdump 这样的工具监听 TCP 的交互,并且立刻就能看到客户端发送给服务器端的消息。
在 Go 中没有像字节流那样专门处理字符流的工具。如何处理字符集和字符编码是非常重要的,我们将会在下一章专门讨论这些问题。
如果和以前一样,处理的所有字符都是 ASCII 码,那么我们能直接又简单地处理这些字符。但是实际上,字符处理复杂的原因是不同的操作系统上有各种不统一的“换行符”。Unix使用简单的'\n' 来表示换行,Windows 和其他的系统(这种方法更正确)使用“\r\n”来表示。在实际的网络传输中,使用一对“\r\n”是更通用的方案 -- 因为 Unix 系统只需要注意不要设定换行符只有“\n”就可以满足这个方案。
简单的例子
这个例子展示的是一个文件夹浏览协议
-- 基本上就是一个简单的 FTP 协议,只是连 FTP的文件传输都没有实现。我们考虑这个例子包含的功能有:展示文件夹名称
,列出文件夹内包含的文件
,改变当前文件夹路径
-- 当然所有这些文件都是在服务器的。这是一个完整的包含客户端和服务器的例子。这个简单的程序既需要两个方向的消息交互,也需要消息的具体协议设计。
在开始例子之前,我们先看一个简单的程序,这个程序不是客户端和服务器交互的程序,它实现的功能包括:展示文件夹中的文件
,打印出文件夹在服务器上的路径
。在这里我们忽略正在拷贝中的文件,因为考虑这些细节会增加代码长度,却对我们要介绍的重要概念没有什么帮助。简单假设:所有的文件名都是 7 位的 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 和文件存储代码连接起来
在包含有客户端和服务器的情况下,客户端就代表用户终端,用来和服务器交互。这个程序最独立的部分就是表现层,比如如何获取用户的命令等。这个程序的消息有的是从客户端到服务器,有的只是在服务器。
对于简单的文件夹浏览器来说,假设所有的文件夹和文件都是在服务器端,我们也只需要从服务器传递文件消息给客户端。客户端的伪代码(包括表现层)应该如下:
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
list directory
,change directory
,print directory
代表需要与服务器进行交互的命令
改变表现层
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 | send list of files |
cd <dir> | change dir, send error if failed, send ok if succeed |
pwd | send current directory |
quit | quit |
文本传输协议
这是一个简单的协议,最复杂的部分就是我们需要使用字符串数组来列出文件夹中内容。所以,我们就不使用最后一章讲到的繁琐复杂的序列化技术了,仅仅使用一种简单的文本格式就好了。
但是实际上,即使我们想尽量使得协议简单,在细节上也需要考虑清楚。我们使用下面的消息格式约定:
- 所有的消息都是 7 位的 US-ASCII 码
- 所有的消息都是大小写敏感
- 每条消息都是由一系列的行组成
- 第一行的第一个单词是用来说明消息类型,其他单词都是具体的消息数据
- 相邻的单词应该只有一个空格符分隔
- 每一行以 CR-LF 作为结束符
实际上,上面的一些考虑在真实的协议中是远远不够的。比如
- 消息类型类型应该是大小不写敏感的。 对于表示消息类型的字符串,我们就需要在解码前将它小写化
- 单词与单词间的多余空白字符应该被丢弃掉。当然这会增加一些代码的复杂度,去处理压缩空白符
- 像“\”这样的续行符应该被使用,它能将一个大的长句子分隔成几行。从这里开始,程序渐渐变得更复杂了
- 像“\n”这样的字符也应该能被解析为换行符,就和“\r\n”一样。这个就让辨识解析程序的结束符更为复杂了
所有以上的变化和考虑都会在真实使用的协议中出现。渐渐地,这些会导致实际的字符处理程序比我们的这个例子复杂。
client request | server response |
---|---|
send "DIR" | send list of files, one per line;terminated by a blank line |
send "CD <dir>" | change dir;send "ERROR" if failed;send "OK" |
send "PWD" | send current working directory |
服务器代码
/* 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 // 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) pwd(conn) 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) {
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*/
package main
import (
"fmt"
"net"
"os"
"bufio"
"strings"
"bytes"
)
// 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')
//删除尾部空格
line = strings.TrimRight(line, " \t\r\n")
if err != nil {
break
}
//以空格作为分隔符,将输入分割成两个子串
strs := strings.SplitN(line, " ", 2)
// decode user request
switch strs[0] {
case uiDir:
dirRequest(conn)
case uiCd:
if len(strs) != 2 {
//cd需要带参数
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 \ "Current dir \""+ s + "\"")
}
func checkError(err error) {
if err != nil {
fmt.Println("Fatal error ", err.Error())
os.Exit(1)
}
}
状态
应用程序经常保存状态消息
来简化下面要做的事情,比如
- 保存当前文件路径的文件指针状态
- 保存当前的鼠标位置状态
- 保存当前的客户值状态
在分布式的系统中,这样的状态消息可能是保存在客户端,服务器,也可能两边都保存。
最重要的一点是,进程是否需要保存 自身进程 或者其他进程 的状态消息。一个进程保存再多自己的状态信息,也不会引发其他问题。如果需要保存其他进程的状态消息,这个问题就复杂了:当前保存的其他进程的状态消息和实际的状态消息可能是不一致的。这可能会引起消息丢失(在 UDP 中)、更新失败、或者 s/w 错误等。
一个例子就是读取文件
。在单个进程中,文件处理代码是应用程序的一部分。它维持一个表,表中包含所有打开的文件和文件指针位置。每次文件读写的时候,文件指针位置就会更新。在数据通信(DCE)文件系统中,文件系统必须追踪客户端打开了哪些文件,客户端的文件指针在哪。如果一个消息丢失了(但是 DCE 是使用 TCP 的),这些状态消息就不能保持同步了。如果出现客户端崩溃了,服务器就必须对这个表触发超时并删除。
在 NFS 文件系统中,服务器并没有保存这个状态消息,而是有客户端保存的。客户端每次在服务器进行的读取文件操作必须能在准确的文件位置打开文件,而这个文件位置是由客户端提供的,从而才能进行后续的操作。
如果由服务器保持客户端的状态消息,服务器必须在客户端崩溃的时候进行修复。如果服务器没有储存状态消息,那么客户端的每次事务交互都需要提供足够的消息来让服务器进行操作。
如果连接是不可靠的,那么必须要有额外的处理程序来确保双方没有失去同步。一个消息丢失的典型例子就是银行账号交易系统。交易系统是客户端与服务器交互的一部分。
应用状态转换图
一个状态转换图清晰说明了当前应用的状态和进入到新的状态需要的转换。
例如:带登陆功能的文件传输:
这个也可以使用一个表来表示
Current state | Transition | Next state |
---|---|---|
login | login failed | login |
login | login succeeded | file transfer |
file transfer | dir | file transfer |
file transfer | get | file transfer |
file transfer | logout | login |
file transfer | quit | - |
客户端状态转换图
客户端状态转换图就和应用转换图一样。不同的就是要注意更多细节:它包含有读
和 写
操作
Current state | Write | Read | Next state |
---|---|---|---|
login | LOGIN name password | FAILED | login |
login | LOGIN name password | SUCCESSED | file transfer |
file transfer | CD dir | SUCCESSED | file transfer |
file transfer | CD dir | FAILED | file transfer |
file transfer | GET filename | #lines + contents | file transfer |
file transfer | GET filename | ERROR | file transfer |
file transfer | DIR | #files + filenames f | file transfer |
file transfer | DIR | ERROR | file transfer |
file transfer | quit | none | file transfer |
logout | none | login | - |
服务器状态转换图
服务器状态转换图也和应用转换图一样。不同的就是也要注意更多细节:它包含有 读
和 写
操作
Current state | Read | Write | Next state |
---|---|---|---|
login | LOGIN name password | FAILED | login |
login | LOGIN name password | SUCCESSED | file transfer |
file transfer | CD dir | SUCCESSED | file transfer |
file transfer | CD dir | FAILED | file transfer |
file transfer | GET filename | #lines + contents | file transfer |
file transfer | GET filename | ERROR | file transfer |
file transfer | DIR | #files + filenames f | file transfer |
file transfer | DIR | ERROR | file transfer |
file transfer | quit | none | file transfer |
logout | 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
...
由于这个伪代码已经足够清晰了,所以我们并不用给出具体的代码了。
总结
任何应用程序在开始编写前都需要详尽的设计。开发一个分布式的系统比开发一个独立系统需要更宽广的视野和思维来做决定和思考。这一章已经考虑到了一些这样的问题,并且展示了最终代码的大致样子