乍一看,经过TCP/IP层链接两个进程会感受可怕, 可是在Go语言中可能比你想象的要简单的多。git
固然不少状况下,不是大多数状况下,使用更高级别的网络协议毫无疑问会更好,由于可使用华丽的API, 它们隐藏了不少技术细节。如今根据不一样的需求,有不少选择,好比消息队列协议, gRPC, protobuf, FlatBuffers, RESTful网站API, websocket等等。github
然而在一些特殊的场景下,特别是小型项目,选择任何其余方式都会感受太臃肿了,更不用说你须要引入额外的依赖包了。golang
幸运的是,使用标准库的net包来建立简单的网络通讯不比你所见到的要困难。web
由于Go语言中有下面两点简化。shell
net.Conn接口实现了io.Reader, io.Writer和io.Closer接口。 所以能够像对待其余io流同样对待TCP链接。编程
你可能会认为:"好,我能在TCP中发送字符串或字节分片,很是不错,可是遇到复杂的数据结构怎么办? 例如咱们遇到的是结构体类型的数据?"json
当说到经过网络发送编码的结构化数据,首先想到的就是JSON。 不过先稍等一下 - Go语言的标准库encoding/gob包提供了一种序列化和发序列话Go数据类型的方法,它无需给结构体、Go语言不兼容的JSON添加字符串标签, 或者等待使用json.Unmarshal来费劲的将文本解析为二进制数据。安全
gob编码解码能够直接操做io流,这一点很完美的匹配第一条简化。bash
下面咱们就经过这两条简化规则一块儿实现一个简单的App。服务器
这个app应该作两件事情:
第一部分,发送简单字符串,将演示无需借助高级协议的状况下,经过TCP/IP网络发送数据是多么简单。
第二部分,稍微深刻一点,经过网络发送完整的结构体,这些结构体使用字符串、分片、映射、甚至包含到自身的递归指针。
辛亏有gob包,要作到这些不费吹灰之力。
客户端 服务端 待发送结构体 解码后结构体 testStruct结构体 testStruct结构体 | ^ V | gob编码 ----------------------------> gob解码 | ^ V | 发送 ============网络================= 接收
发送字符串须要三个简单的步骤:
net包提供了一对实现这个功能的方法。
这两个方法都是在go源码的src/net/tcpsock.go文件中定义的。
func ResolveTCPAddr(network, address string) (*TCPAddr, error) { switch network { case "tcp", "tcp4", "tcp6": case "": // a hint wildcard for Go 1.0 undocumented behavior network = "tcp" default: return nil, UnknownNetworkError(network) } addrs, err := DefaultResolver.internetAddrList(context.Background(), network, address) if err != nil { return nil, err } return addrs.forResolve(network, address).(*TCPAddr), nil }
ResolveTCPAddr()接收两个字符串参数。
ResolveTCPAddr()接收的表明TCP地址的字符串(例如localhost:80, 127.0.0.1:80, 或[::1]:80, 都是表明本机的80端口), 返回(net.TCPAddr指针, nil)(若是字符串不能被解析成有效的TCP地址会返回(nil, error))。
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) { switch network { case "tcp", "tcp4", "tcp6": default: return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: UnknownNetworkError(network)} } if raddr == nil { return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: nil, Err: errMissingAddress} } c, err := dialTCP(context.Background(), network, laddr, raddr) if err != nil { return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: err} } return c, nil }
DialTCP()函数接收三个参数:
它会链接拨号两个TCP地址,并返回这个链接做为net.TCPConn对象返回(链接失败返回error)。若是咱们不须要对Dial设置有过多控制,那么咱们就可使用Dial()代替。
func Dial(network, address string) (Conn, error) { var d Dialer return d.Dial(network, address) }
Dial()函数接收一个TCP地址,返回一个通常的net.Conn。 这已经足够咱们的测试用例了。然而若是你须要只有在TCP链接上的可用功能,可使用TCP变体(DialTCP, TCPConn, TCPAddr等等)。
成功拨号以后,咱们就能够如上所述的那样,将新的链接与其余的输入输出流同等对待了。咱们甚至能够将链接包装进bufio.ReadWriter中,这样可使用各类ReadWriter方法,例如ReadString(), ReadBytes, WriteString等等。
func Open(addr string) (*bufio.ReadWriter, error) { conn, err := net.Dial("tcp", addr) if err != nil { return nil, errors.Wrap(err, "Dialing "+addr+" failed") } // 将net.Conn对象包装到bufio.ReadWriter中 return bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil }
记住缓冲Writer在写以后须要调用Flush()方法, 这样全部的数据才会刷到底层网络链接中。
最后,每一个链接对象都有一个Close()方法来终止通讯。
Dialer结构体定义以下:
type Dialer struct { Timeout time.Duration Deadline time.Time LocalAddr Addr DualStack bool FallbackDelay time.Duration KeepAlive time.Duration Resolver *Resolver Cancel <-chan struct{} }
有两个可用选项能够微调。
所以Dialer接口提供了能够微调的两方面选项:
type Conn interface { Read(b []byte) (n int, err error) Write(b []byte) (n int, err error) Close() error LocalAddr() Addr RemoteAddr() Addr SetDeadline(t time.Time) error SetReadDeadline(t time.Time) error SetWriteDeadline(t time.Time) error }
net.Conn接口是面向流的通常的网络链接。它具备下面这些接口方法:
Conn接口也有deadline设置; 有对整个链接的(SetDeadLine()),也有特定读写调用的(SetReadDeadLine()和SetWriteDeadLine())。
注意deadline是(wallclock)时间固定点。和timeout不一样,它们新活动以后不会重置。所以链接上的每一个活动必须设置新的deadline。
下面的样本代码没有使用deadline, 由于它足够简单,咱们能够很容易看到何时会被卡住。Ctrl-C时咱们手动触发deadline的工具。
接收端步骤以下:
监听须要指定本地监听的端口号。通常来讲,监听应用程序(也叫server)宣布监听的端口号,若是提供标准服务, 那么使用这个服务对应的相关端口。例如,web服务一般监听80来伺服HTTP, 443端口伺服HTTPS请求。 SSH守护默认监听22端口, WHOIS服务使用端口43。
type Listener interface { // Accept waits for and returns the next connection to the listener. Accept() (Conn, error) // Close closes the listener. // Any blocked Accept operations will be unblocked and return errors. Close() error // Addr returns the listener's network address. Addr() Addr }
func Listen(network, address string) (Listener, error) { addrs, err := DefaultResolver.resolveAddrList(context.Background(), "listen", network, address, nil) if err != nil { return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: nil, Err: err} } var l Listener switch la := addrs.first(isIPv4).(type) { case *TCPAddr: l, err = ListenTCP(network, la) case *UnixAddr: l, err = ListenUnix(network, la) default: return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: la, Err: &AddrError{Err: "unexpected address type", Addr: address}} } if err != nil { return nil, err // l is non-nil interface containing nil pointer } return l, nil }
net包实现服务端的核心部分是:
net.Listen()在给定的本地网络地址上来建立新的监听器。若是只传端口号给它,例如":61000", 那么监听器会监听全部可用的网络接口。 这至关方便,由于计算机一般至少提供两个活动接口,回环接口和最少一个真实网卡。 这个函数成功的话返回Listener。
Listener接口有一个Accept()方法用来等待请求进来。而后它接受请求,并给调用者返回新的链接。Accept()通常来讲都是在循环中调用,可以同时服务多个链接。每一个链接能够由一个单独的goroutine处理,正以下面代码所示的。
与其让代码来回推送一些字节,我更想要它演示一些更有用的东西。 我想让它能给服务器发送带有不一样数据载体的不一样命令。服务器应该能标识每一个命令和解码命令数据。
咱们代码中客户端会发送两种类型的命令: "STRING"和"GOB"。它们都以换行符终止。
"STRING"命令包含一行字符串数据,能够经过bufio中的简单读写操做来处理。
"GOB"命令由结构体组成,这个结构体包含一些字段,包含一个分片和映射,甚至指向本身的指针。 正如你所见,当运行这个代码时,gob包能经过咱们的网络链接移动这些数据没有什么稀奇(fuss).
咱们这里基本上都是一些即席协议(ad-hoc protocol: 特设的、特定目的的、即席的、专案的), 客户端和服务端都遵循它,命令行后面是换行,而后是数据。对于每一个命令来讲,服务端必须知道数据的确切格式,知道如何处理它。
要达到这个目的,服务端代码采起两步方式实现。
package main import ( "bufio" "encoding/gob" "flag" "github.com/pkg/errors" "io" "log" "net" "strconv" "strings" "sync" ) type complexData struct { N int S string M map[string]int P []byte C *complexData } const ( Port = ":61000" )
使用发射链接是一种快照。net.Conn知足io.Reader和io.Writer接口,所以咱们能够将TCP链接和其余任何的Reader和Writer同样看待。
func Open(addr string) (*bufio.ReadWriter, error) { log.Println("Dial " + addr) conn, err := net.Dial("tcp", addr) if err != nil { return nil, errors.Wrap(err, "Dialing " + addr + " failed") } return bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)), nil }
打开TCP地址的链接。它返回一个带有超时的TCP链接,并将其包装进缓冲的ReadWriter。拨号远程进程。注意本地端口是实时(on the fly)分配的。若是必须指定本地端口号,请使用DialTCP()方法。
这节有点涉及到对进入数据的准备环节处理。根据咱们前面介绍的ad-hoc协议,命令名+换行符+数据+换行符。天然数据是和具体命令相关的。要处理这样的状况,咱们建立了一个Endpoint对象,它具备下面的属性:
首先咱们声明一个HandleFunc类型,该类型为接收一个bufio.ReadWriter指针值的函数类型, 也就是后面咱们要为每种不一样命令注册的处理器函数。它接收的参数是使用ReadWriter接口包装的net.Conn链接。
type HandleFunc func(*bufio.ReadWriter)
而后咱们声明一个Endpoint结构体类型,它有三个属性:
type Endpoint struct { listener net.Listener handler map[string]HandleFunc m sync.RWMutex // Maps不是线程安全的,所以须要互斥锁来控制访问。 } func NewEndpoint() *Endpoint { return &Endpoint{ handler: map[string]HandleFunc{}, } } func (e *Endpoint) AddHandleFunc(name string, f HandleFunc) { e.m.Lock() e.handler[name] = f e.m.Unlock() } func (e *Endpoint) Listen() error { var err error e.listener, err = net.Listen("tcp", Port) if err != nil { return errors.Wrap(err, "Unable to listen on "+e.listener.Addr().String()+"\n") } log.Println("Listen on", e.listener.Addr().String()) for { log.Println("Accept a connection request.") conn, err := e.listener.Accept() if err != nil { log.Println("Failed accepting a connection request:", err) continue } log.Println("Handle incoming messages.") go e.handleMessages(conn) } } // handleMessages读取链接到第一个换行符。 基于这个字符串,它会调用恰当的HandleFunc。 func (e *Endpoint) handleMessages(conn net.Conn) { // 将链接包装到缓冲reader以便于读取 rw := bufio.NewReadWrite(bufio.NewReader(conn), bufio.NewWriter(conn)) defer conn.Close() // 从链接读取直到遇到EOF. 指望下一次输入是命令名。调用注册的用于该命令的处理器。 for { log.Print("Receive command '") cmd, err := rw.ReadString('\n') switch { case err == io.EOF: log.Println("Reached EOF - close this connection.\n ---") return case err != nil: log.Println("\nError reading command. Got: '" + cmd + "'\n", err) } // 修剪请求字符串中的多余回车和空格- ReadString不会去掉任何换行。 cmd = strings.Trim(cmd, "\n ") log.Println(cmd + "'") // 从handler映射中获取恰当的处理器函数, 并调用它。 e.m.Lock() handleCommand, ok := e.handler[cmd] e.m.Unlock() if !ok { log.Println("Command '" + cmd + "' is not registered.") return } handleCommand(rw) } }
NewEndpoint()函数是Endpoint的工厂函数。它只对handler映射进行了初始化。为了简化问题,假设咱们的终端监听的端口好是固定的。
Endpoint类型声明了几个方法:
注意上面如何使用动态函数的。 根据命令名查找具体函数,而后这个具体函数赋值给handleCommand, 其实这个变量类型为HandleFunc类型, 即前面声明的处理器函数类型。
Endpoint的Listen方法调用以前须要先至少注册一个处理器函数。所以咱们下面定义两个类型的处理器函数: handleStrings和handleGob。
handleStrings()函数接收和处理咱们即时协议中只发送字符串数据的处理器函数。handleGob()函数是接收并处理发送的gob数据的复杂结构体。handleGob稍微复杂一点,除了读取数据外,咱们海须要解码数据。
咱们能够看到连续两次使用rw.ReadString('n'), 读取字符串,遇到换行中止, 将读到的内容保存到字符串中。注意这个字符串是包含末尾换行的。
另外对于普通字符串数据来讲,咱们直接用bufio包装链接后的ReadString来读取。而对于复杂的gob结构体来讲,咱们使用gob来解码数据。
func handleStrings(rw *bufio.ReadWriter) { log.Print("Receive STRING message:") s, err := rw.ReadString('\n') if err != nil { log.Println("Cannot read from connection.\n", err) } s = strings.Trim(s, "\n ") log.Println(s) -, err = rw.WriteString("Thank you.\n") if err != nil { log.Println("Cannot write to connection.\n", err) } err = rw.Flush() if err != nil { log.Println("Flush failed.", err) } } func handleGob(rw *bufio.ReadWriter) { log.Print("Receive GOB data:") var data complexData dec := gob.NewDecoder(rw) err := dec.Decode(&data) if err != nil { log.Println("Error decoding GOB data:", err) return } log.Printf("Outer complexData struct: \n%#v\n", data) log.Printf("Inner complexData struct: \n%#v\n", data.C) }
一切就绪,咱们能够准备咱们的客户端和服务端函数了。
// 当应用程序使用-connect=ip地址的时候被调用 func client(ip string) error { testStruct := complexData{ N: 23, S: "string data", M: map[string]int{"one": 1, "two": 2, "three": 3}, P: []byte("abc"), C: &complexData{ N: 256, S: "Recursive structs? Piece of cake!", M: Map[string]int{"01": "10": 2, "11": 3}, }, } rw, err := Open(ip + Port) if err != nil { return errors.Wrap(err, "Client: Failed to open connection to " + ip + Port) } log.Println("Send the string request.") n, err := rw.WriteString("STRING\n") if err != nil { return errors.Wrap(err, "Could not send the STRING request (" + strconv.Itoa(n) + " bytes written)") } // 发送STRING请求。发送请求名并发送数据。 log.Println("Send the string request.") n, err = rw.WriteString("Additional data.\n") if err != nil { return errors.Wrap(err, "Could not send additional STRING data (" + strconv.Itoa(n) + " bytes written)") } log.Println("Flush the buffer.") err = rw.Flush() if err != nil { return errors.Wrap(err, "Flush failed.") } // 读取响应 log.Println("Read the reply.") response, err := rw.ReadString('\n') if err != nil { return errors.Wrap(err, "Client: Failed to read the reply: '" + response + "'") } log.Println("STRING request: got a response:", response) // 发送GOB请求。 建立一个encoder直接将它转换为rw.Send的请求名。发送GOB log.Println("Send a struct as GOB:") log.Printf("Outer complexData struct: \n%#v\n", testStruct) log.Printf("Inner complexData struct: \n%#v\n", testStruct.C) enc := gob.NewDecoder(rw) n, err = rw.WriteString("GOB\n") if err != nil { return errors.Wrap(err, "Could not write GOB data (" + strconv.Itoa(n) + " bytes written)") } err = enc.Encode(testStruct) if err != nil { return errors.Wrap(err, "Encode failed for struct: %#v", testStruct) } err = rw.Flush() if err != nil { return errors.Wrap(err, "Flush failed.") } return nil }
客户端函数在执行应用程序时指定connect标志的时候执行,这点后面的代码能够看到。
下面是服务端程序server。服务端监听进来的请求并根据请求命令名将它们调度给注册的具体相关处理器。
func server() error { endpoint := NewEndpoint() // 添加处理器函数 endpoint.AddHandleFunc("STRING", handleStrings) endpoint.AddHandleFunc("GOB", handleGOB) // 开始监听 return endpoint.Listen() }
下面的main函数既能够启动客户端也能够启动服务端, 依赖因而否设置connect标志。 若是没有这个标志,则以服务器启动进程, 监听进来的请求。若是有标志, 启动为客户端,并链接到这个标志指定的主机。
可使用localhost或127.0.0.1在同一机器上运行这两个进程。
func main() { connect := flag.String("connect", "", "IP address of process to join. If empty, go into the listen mode.") flag.Parse() // 若是设置了connect标志,进入客户端模式 if *connect != '' { err := client(*connect) if err != nil { log.Println("Error:", errors.WithStack(err)) } log.Println("Client done.") return } // 不然进入服务端模式 err := server() if err != nil { log.Println("Error:", errors.WithStack(err)) } log.Println("Server done.") } // 设置日志记录的字段标志 func init() { log.SetFlags(log.Lshortfile) }
第一步: 获取代码。 注意-d标志自动安装二进制到$GOPATH/bin目录。
go get -d github.com/appliedgo/networking
第二步: cd到源代码目录。
cd $GOPATH/src/github.com/appliedgo/networking
第三步: 运行服务端。
go run networking.go
第四步: 打开另一个shell, 一样进入到源码目录(第二步), 而后运行客户端。
go run networking.go -connect localhost
若是你想稍微修改下源代码,下面是一些建议:
2017-02-09: map不是线程安全的,所以若是在不一样的goroutine中使用同一个map, 应该使用互斥锁来控制map的访问。
而上面的代码,map在goroutine启动以前已经添加好了, 所以你能够安全的修改代码,在handleMessages goroutine已经运行的时候调用AddHandleFunc()。
---- 2018-05-04 -----