Go语言中的TCP/IP网络编程

Go语言TCP/IP网络编程

乍一看,经过TCP/IP层链接两个进程会感受可怕, 可是在Go语言中可能比你想象的要简单的多。git

TCP/IP层发送数据的应用场景

固然不少状况下,不是大多数状况下,使用更高级别的网络协议毫无疑问会更好,由于可使用华丽的API, 它们隐藏了不少技术细节。如今根据不一样的需求,有不少选择,好比消息队列协议, gRPC, protobuf, FlatBuffers, RESTful网站API, websocket等等。github

然而在一些特殊的场景下,特别是小型项目,选择任何其余方式都会感受太臃肿了,更不用说你须要引入额外的依赖包了。golang

幸运的是,使用标准库的net包来建立简单的网络通讯不比你所见到的要困难。web

由于Go语言中有下面两点简化。shell

简化1: 链接就是io流

net.Conn接口实现了io.Reader, io.Writer和io.Closer接口。 所以能够像对待其余io流同样对待TCP链接。编程

你可能会认为:"好,我能在TCP中发送字符串或字节分片,很是不错,可是遇到复杂的数据结构怎么办? 例如咱们遇到的是结构体类型的数据?"json

简化2: Go语言知道如何有效的解码复杂的类型

当说到经过网络发送编码的结构化数据,首先想到的就是JSON。 不过先稍等一下 - Go语言的标准库encoding/gob包提供了一种序列化和发序列话Go数据类型的方法,它无需给结构体、Go语言不兼容的JSON添加字符串标签, 或者等待使用json.Unmarshal来费劲的将文本解析为二进制数据。安全

gob编码解码能够直接操做io流,这一点很完美的匹配第一条简化。bash

下面咱们就经过这两条简化规则一块儿实现一个简单的App。服务器

这个简单APP的目标

这个app应该作两件事情:

  • 发送和接收简单的字符串消息。
  • 经过gob发送和接收结构体。

第一部分,发送简单字符串,将演示无需借助高级协议的状况下,经过TCP/IP网络发送数据是多么简单。
第二部分,稍微深刻一点,经过网络发送完整的结构体,这些结构体使用字符串、分片、映射、甚至包含到自身的递归指针。

辛亏有gob包,要作到这些不费吹灰之力。

客户端                                        服务端

待发送结构体                                解码后结构体
testStruct结构体                            testStruct结构体
    |                                             ^
    V                                             |
gob编码       ---------------------------->     gob解码
    |                                             ^
    V                                             |   
   发送     ============网络=================    接收

经过TCP发送字符串数据的基本要素

发送端上

发送字符串须要三个简单的步骤:

  • 打开对应接收进程的链接。
  • 写字符串。
  • 关闭链接。

net包提供了一对实现这个功能的方法。

  • ResolveTCPAddr(): 该函数返回TCP终端地址。
  • DialTCP(): 相似于TCP网络的拨号。

这两个方法都是在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()接收两个字符串参数。

  • network: 必须是TCP网络名,好比tcp, tcp4, tcp6。
  • address: TCP地址字符串,若是它不是字面量的IP地址或者端口号不是字面量的端口号, ResolveTCPAddr会将传入的地址解决成TCP终端的地址。不然传入一对字面量IP地址和端口数字做为地址。address参数可使用host名称,可是不推荐这样作,由于它最多会返回host名字的一个IP地址。

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()函数接收三个参数:

  • network: 这个参数和ResolveTCPAddr的network参数同样,必须是TCP网络名。
  • laddr: TCPAddr类型的指针, 表明本地TCP地址。
  • raddr: TCPAddr类型的指针,表明的是远程TCP地址。

它会链接拨号两个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()方法来终止通讯。

微调(fine tuning)

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{}
}
  • Timeout: 拨号等待链接结束的最大时间数。若是同时设置了Deadline, 能够更早失败。默认没有超时。 当使用TCP并使用多个IP地址拨号主机名,超时会在它们之间划分。使用或不使用超时,操做系统均可以强迫更早超时。例如,TCP超时通常在3分钟左右。
  • Deadline: 是拨号即将失败的绝对时间点。若是设置了Timeout, 可能会更早失败。0值表示没有截止期限, 或者依赖操做系统或使用Timeout选项。
  • LocalAddr: 是拨号一个地址时使用的本地地址。这个地址必须是要拨号的network地址彻底兼容的类型。若是为nil, 会自动选择一个本地地址。
  • DualStack: 这个属性能够启用RFC 6555兼容的"欢乐眼球(Happy Eyeballs)"拨号,当network是tcp时,address参数中的host能够被解析被IPv4和IPv6地址。这样就容许客户端容忍(tolerate)一个地址家族的网络规定稍微打破一下。
  • FallbackDelay: 当DualStack启用的时候, 指定在产生回退链接以前须要等待的时间。若是设置为0, 默认使用延时300ms。
  • KeepAlive: 为活动网络链接指定保持活动的时间。若是设置为0,没有启用keep-alive。不支持keep-alive的网络协议会忽略掉这个字段。
  • Resolver: 可选项,指定使用的可替代resolver。
  • Cancel: 可选通道,它的闭包表示拨号应该被取消。不是全部的拨号类型都支持拨号取消。 已废弃,可以使用DialContext代替。

有两个可用选项能够微调。

所以Dialer接口提供了能够微调的两方面选项:

  • DeadLine和Timeout选项: 用于不成功拨号的超时设置。
  • KeepAlive选项: 管理链接的使用寿命(life span)。
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接口是面向流的通常的网络链接。它具备下面这些接口方法:

  • Read(): 从链接上读取数据。
  • Write(): 向链接上写入数据。
  • Close(): 关闭链接。
  • LocalAddr(): 返回本地网络地址。
  • RemoteAddr(): 返回远程网络地址。
  • SetDeadline(): 设置链接相关的读写最后期限。等价于同时调用SetReadDeadline()和SetWriteDeadline()。
  • SetReadDeadline(): 设置未来的读调用和当前阻塞的读调用的超时最后期限。
  • SetWriteDeadline(): 设置未来写调用以及当前阻塞的写调用的超时最后期限。

Conn接口也有deadline设置; 有对整个链接的(SetDeadLine()),也有特定读写调用的(SetReadDeadLine()和SetWriteDeadLine())。

注意deadline是(wallclock)时间固定点。和timeout不一样,它们新活动以后不会重置。所以链接上的每一个活动必须设置新的deadline。

下面的样本代码没有使用deadline, 由于它足够简单,咱们能够很容易看到何时会被卡住。Ctrl-C时咱们手动触发deadline的工具。

接收端上

接收端步骤以下:

  • 对本地端口打开监听。
  • 当请求到来时,产生(spawn)goroutine来处理请求。
  • 在goroutine中,读取数据。也能够选择性的发送响应。
  • 关闭链接。

监听须要指定本地监听的端口号。通常来讲,监听应用程序(也叫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: 特设的、特定目的的、即席的、专案的), 客户端和服务端都遵循它,命令行后面是换行,而后是数据。对于每一个命令来讲,服务端必须知道数据的确切格式,知道如何处理它。

要达到这个目的,服务端代码采起两步方式实现。

  • 第一步: 当Listen()函数接收到新链接,它会产生一个新的goroutine来调用handleMessage()。 这个函数从链接中读取命令名, 从映射中查询合适的处理器函数,而后调用它。
  • 第二步: 选择的处理器函数读取并处理命令行的数据。
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"
)

Outcoing connections(发射链接)

使用发射链接是一种快照。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结构体类型,它有三个属性:

  • listener: net.Listen()返回的Listener对象。
  • handler: 用于保存已注册的处理器函数的映射。
  • m: 一个互斥锁,用于解决map的多goroutine不安全的问题。
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类型声明了几个方法:

  • AddHandleFunc(): 使用互斥锁为handler属性安全添加处理特定类型命令的处理器函数。
  • Listen(): 对终端端口的全部接口启动监听。 在调用Listen以前,至少要经过AddHandleFunc()注册一个handler函数。
  • HandleMessages(): 将链接用bufio包装起来,而后分两步读取,首先读取命令加换行,咱们获得命令名字。 而后经过handler获取注册的该命令对应的处理器函数, 而后调度这个函数来执行数据读取和解析。
注意上面如何使用动态函数的。 根据命令名查找具体函数,而后这个具体函数赋值给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)
}

客户端和服务端函数

一切就绪,咱们能够准备咱们的客户端和服务端函数了。

  • 客户端函数链接到服务器并发送STRING和GOB请求。
  • 服务端开始监听请求并触发恰当的处理器。
// 当应用程序使用-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函数

下面的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

Tips

若是你想稍微修改下源代码,下面是一些建议:

  • 在不一样机器运行客户端和服务端(同一个局域网中).
  • 用更多的映射和指针来加强(beef up)complexData, 看看gob如何应对它(cope with it)。
  • 同时启动多个客户端,看看服务端是否能处理它们。
2017-02-09: map不是线程安全的,所以若是在不一样的goroutine中使用同一个map, 应该使用互斥锁来控制map的访问。
而上面的代码,map在goroutine启动以前已经添加好了, 所以你能够安全的修改代码,在handleMessages goroutine已经运行的时候调用AddHandleFunc()。

本章所学知识点总结

---- 2018-05-04 -----

  • bufio的应用。
  • gob的应用。
  • map在多goroutine间共享时是不安全的。

参考连接

相关文章
相关标签/搜索