Go socket编程实践: TCP服务器和客户端实现

这篇文章主要使用Go语言实现一个简单的TCP服务器和客户端。
服务器和客户端之间的协议是 ECHO, 这个RFC 862定义的一个简单协议。
为什么说这个协议很简单呢, 这是因为服务器只需把收到的客户端的请求数据发给这个客户端即可,其它什么功能都不做。

首先声明, 我绝对是一个Golang的初学者,十四、五年的编程时间我主要使用Java来做开发,这篇文章主要记录我学习go网络编程的体验。如果你认为这篇文章有错误或者不好的写法,请在回复中添加你的意见。

简单介绍

尽管OSI(开放系统互联)协议从未被完整地实现过,但它仍对分布式系统的讨论和设计产生了十分重要的影响。它的结构大致为下图所示:

当OSI标准模型正在为实现细节闹得不可开交时,DARPA互联网技术项目却在忙着构建TCP/IP协议。它们取得了极大的成功,并引领了Internet(首字母大写),因为这是个更简单的层次结构:

尽管现在到处都是TCP/IP协议,但它并不是唯一存在的。还有些协议占有重要的地位,比如:

  • Firewire
  • USB
  • Bluetooth
  • WiFi

多年的发展,使得IP和TCP/UDP协议基本上就等价于网络协议栈。例如, 蓝牙定义了物理层和协议层,但在其上任然是IP协议栈,可以在许多蓝牙设备之间使用互联网编程技术。同样, 开发4G无线手机技术,如LTE(Long Term Evolution)也将使用IP协议栈。

在OIS或TCP/IP协议栈层与层之间的通信,是通过将数据包从一个层发送到下一个层,最终穿过整个网络的。每一层都有必须保持其自身层的管理信息。从上层接收到的数据包在向下传递时,会添加头信息。在接收端,这些头信息会在向上传递时移除。
例如,TFTP(普通文件传输协议)将文件从一台计算机移动到另一台上。它使用IP协议上的UDP协议,该协议可通过以太网发送。看起来就像这样

最终在以太网上传输的数据,就是图中最底层的那个数据。

为了两个计算机进行通信,就必须建立一个路径,使他们能够在一个会话中发送至少一条消息。有两个主要的模型:

  • 面向连接模型, 如TCP
  • 无连接模型, 如UDP, IP

服务运行在主机。通常它们的生命期很长,同时被设计成等待请求和响应请求。当前有各种类型的服务,通过各种方法向客户提供服务。互联网的世界基于TCP和UDP这两种通信方法提供许多这些服务,虽然也有其他通信协议如SCTP​​伺机取代。许多其他类型的服务,例如点对点, 远过程调用, 通信代理, 和许多其他也建立在TCP和UDP之上。

服务存活于主机内。IP地址可以定位主机。但在每台计算机上可能会提供多种服务,需要一个简单的方法对它们加以区分。TCP,UDP,SCTP或者其他协议使用端口号来加以区分。这里使用一个1到65,535的无符号整数,每个服务将这些端口号中的一个或多个相关联。

有很多“标准”的端口。Telnet服务通常使用端口号23的TCP协议。DNS使用端口号53的TCP或UDP协议。FTP使用端口21和20的命令,进行数据传输。HTTP通常使用端口80,但经常使用,端口8000,8080和8088,协议为TCP。X Window系统往往需要端口6000-6007,TCP和UDP协议。

在Unix系统中, /etc/services文件列出了常用的端口。Go语言有一个函数可以获取该文件。

1
func LookupPort(network, service string) (port int, err os.Error)

Go提供IP, IP掩码, TCPAddr, UDPAddr, 网卡,主机查询这些对象的操作函数。

当你知道如何通过网络和端口ID查找一个服务时,然后呢?如果你是一个客户端,你需要一个API,让您连接到服务,然后将消息发送到该服务,并从服务读取回复。
如果你是一个服务器,你需要能够绑定到一个端口,并监听它。当有消息到来,你需要能够读取它并回复客户端。

net.TCPConn是允许在TCP客户端和TCP服务器之间的全双工通信的Go类型。两种主要方法是

1
2
func (c *TCPConn) Write(b []byte) (n int, err os.Error)
func (c *TCPConn) Read(b []byte) (n int, err os.Error)

TCPConn被客户端和服务器用来读写消息。

ECHO服务器

在一个服务器上注册并监听一个端口。然后它阻塞在一个"accept"操作,并等待客户端连接。当一个客户端连接, accept调用返回一个连接(connection)对象。ECHO服务非常简单,只是客户端, 关闭该连接的请求数据写回到客户端,就像回声一样,直到某一方关闭连接。

1
2
func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
func (l *TCPListener) Accept() (c Conn, err os.Error)

net参数可以设置为字符串"tcp", "tcp4"或者"tcp6"中的一个。如果你想监听所有网络接口,IP地址应设置为0。 如果你只是想监听一个特定网络接口,IP地址可以设置为该网络接口的地址。如果端口设置为0,操作系统会为你选择一个端口。否则,你可以选择你自己的。需要注意的是,在Unix系统上,除非你是监控系统,否则不能监听低于1024的端口,小于128的端口是由IETF标准化。该示例程序选择端口1200没有特别的原因。TCP地址如下":1200" - 所有网络接口, 端口1200。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main
import (
"flag"
"fmt"
"io"
"net"
"os"
)
var host = flag.String("host", "", "host")
var port = flag.String("port", "3333", "port")
func main() {
flag.Parse()
var l net.Listener
var err error
l, err = net.Listen("tcp", *host+":"+*port)
if err != nil {
fmt.Println("Error listening:", err)
os.Exit(1)
}
defer l.Close()
fmt.Println("Listening on " + *host + ":" + *port)
for {
conn, err := l.Accept()
if err != nil {
fmt.Println("Error accepting: ", err)
os.Exit(1)
}
//logs an incoming message
fmt.Printf("Received message %s -> %s \n", conn.RemoteAddr(), conn.LocalAddr())
// Handle connections in a new goroutine.
go handleRequest(conn)
}
}
func handleRequest(conn net.Conn) {
defer conn.Close()
for {
io.Copy(conn, conn)
}
}

执行go run echoserver.go启动服务器。

ECHO客户端

一旦客户端已经建立TCP服务, 就可以"拨号"了. 如果成功,该调用返回一个用于通信的TCPConn。客户端和服务器通过它交换消息。通常情况下,客户端使用TCPConn写入请求到服务器, 并从TCPConn的读取响应。持续如此,直到任一(或两者)的两侧关闭连接。客户端使用该函数建立一个TCP连接。

1
func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error)

其中laddr是本地地址,通常设置为nilraddr是一个服务的远程地址, net是一个字符串,可以根据你的需要设置为"tcp4", "tcp6"或"tcp"中的一个。

在介绍实现时,我们需要介绍同步机制, 因为客户端发送和接收是在两个goroutine中。 main函数中如果不加上同步机制, 客户端还没有发送接收就执行完了。
我们实现了两种同步方式。 当然还有其它方式, 如time.Sleep(60*1000)或者等待从命令行输入,不过看起来有点傻。

Go格言
Share memory by communicating, don't communicate by sharing memory

使用Channel等待goroutine完成

比如老套的方式通过channel实现同步。 读和写完成后分别往channel中写入"done"。 main读取channel中的值,当两个done都读取到后就知道读写已经完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main
import (
"flag"
"fmt"
"net"
"os"
"strconv"
)
var host = flag.String("host", "localhost", "host")
var port = flag.String("port", "3333", "port")
func main() {
flag.Parse()
conn, err := net.Dial("tcp", *host+":"+*port)
if err != nil {
fmt.Println("Error connecting:", err)
os.Exit(1)
}
defer conn.Close()
fmt.Println("Connecting to " + *host + ":" + *port)
done := make(chan string)
go handleWrite(conn, done)
go handleRead(conn, done)
fmt.Println(<-done)
fmt.Println(<-done)
}
func handleWrite(conn net.Conn, done chan string) {
for i := 10; i > 0; i-- {
_, e := conn.Write([]byte("hello " + strconv.Itoa(i) + "\r\n"))
if e != nil {
fmt.Println("Error to send message because of ", e.Error())
break
}
}
done <- "Sent"
}
func handleRead(conn net.Conn, done chan string) {
buf := make([]byte, 1024)
reqLen, err := conn.Read(buf)
if err != nil {
fmt.Println("Error to read message because of ", err)
return
}
fmt.Println(string(buf[:reqLen-1]))
done <- "Read"
}

net.Dial建立连接, handleWrite发送十个请求, handleRead 接收服务器的响应。一旦完成,往channel中写done。
执行go run echoclient.go启动服务器。

使用WaitGroup等待goroutine完成

上面的方式虽好,但是不够灵活。我们需要明确知道有多少个done。 如果增加若干个goroutine,修改起来比较麻烦。
所以还是使用sync包的WaitGroup比较灵活。 它类似Java中的CountDownLatch。

A WaitGroup waits for a collection of goroutines to finish. The main goroutine calls Add to set the number of goroutines to wait for. Then each of the goroutines runs and calls Done when finished. At the same time, Wait can be used to block until all goroutines have finished.

将上面的例子修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main
import (
"bufio"
"flag"
"fmt"
"net"
"os"
"strconv"
"sync"
)
var host = flag.String("host", "localhost", "host")
var port = flag.String("port", "3333", "port")
func main() {
flag.Parse()
conn, err := net.Dial("tcp", *host+":"+*port)
if err != nil {
fmt.Println("Error connecting:", err)
os.Exit(1)
}
defer conn.Close()
fmt.Println("Connecting to " + *host + ":" + *port)
var wg sync.WaitGroup
wg.Add(2)
go handleWrite(conn, &wg)
go handleRead(conn, &wg)
wg.Wait()
}
func handleWrite(conn net.Conn, wg *sync.WaitGroup) {
defer wg.Done()
for i := 10; i > 0; i-- {
_, e := conn.Write([]byte("hello " + strconv.Itoa(i) + "\r\n"))
if e != nil {
fmt.Println("Error to send message because of ", e.Error())
break
}
}
}
func handleRead(conn net.Conn, wg *sync.WaitGroup) {
defer wg.Done()
reader := bufio.NewReader(conn)
for i := 1; i <= 10; i++ {
line, err := reader.ReadString(byte('\n'))
if err != nil {
fmt.Print("Error to read message because of ", err)
return
}
fmt.Print(line)
}
}

wg.Add(2)设定等待两个goroutines, 然后调用wg.Wait()等待goroutines完成。 当goroutine完成时, 调用wg.Done()。 使用起来相当简洁。

参考

  1. http://tools.ietf.org/html/rfc862
  2. http://loige.com/simple-echo-server-written-in-go-dockerized/
  3. http://nathanleclaire.com/blog/2014/02/15/how-to-wait-for-all-goroutines-to-finish-executing-before-continuing/
  4. http://jan.newmarch.name/go/zh/
  5. https://talks.golang.org/2012/concurrency.slide
  6. http://jimmyfrasche.github.io/go-reflection-codex/
  7. https://sites.google.com/site/gopatterns/
  8. https://github.com/golang-samples
  9. http://golang-examples.tumblr.com/
  10. https://code.google.com/p/go-wiki/wiki/Articles
  11. https://github.com/mindreframer/golang-stuff