网上关于 Golang 的入门教程和学习资料有很多,我也是通过官网的学习材料自学的,但其中的大多数例子都很简单,有现实意义的并不多。我觉得有必要开发一个比较真实的且能够综合运用各种知识的经典的实践项目,把开发项目的详细经过和相关知识点整理成系统的教程,让后面更多的初学者可以多一个好的练手项目。我思考了很久,决定开发一个基于 Terminal 的即时通讯软件,分为服务端和客户端两个程序。

第一章 Go 起步

这一章节,会简短的介绍一下编程语言 Go,你可以学习到:

  • 安装 Go (如果你还未安装)
  • 写 "Hello, World" 代码
  • 用 Go 命令运行代码
  • 用 Go 包发现工具找到你的代码中引用的包
  • 调用外部模块的函数
  • 安装 Git (如果你还未安装)

前置条件

  • 一点点其他编程语言经验
    这里的代码很简单,如果了解函数的概念会有帮助
  • 代码编辑器
    任何代码编辑器都可以,大部分编辑器对 Go 都有良好的支持。最受欢迎的是 VSCode (免费),GoLand (付费) 和 Vim (免费)。
  • 命令终端
    Go 在 Linux/Mac 的 Terminal,还有 Windows 的 PowerShell 或 cmd 上工作良好。

本章节主要内容翻译自官方 tutorial

1.1 安装 Go

Go 下载

Linux (下载 go1.19.4.linux-amd64.tar.gz)

1.如果系统中存在 /usr/local/go 目录,通过删除该目录,可以删除任何之前已安装的 Go。然后把刚刚下载好的压缩文件解压到 /usr/local 目录,全新的 Go 会重新安装在 /usr/local/go:

rm -rf /usr/local/go && tar -C /usr/local -xzf go1.19.4.linux-amd64.tar.gz

(可能需要以 root 用户或者通过 sudo 来运行上面的命令)

2.把 /usr/local/go/bin 添加到环境变量 PATH 中

你可以把下面这行脚本添加到 $HOME/.profile 或 /etc/profile (系统级安装)

export PATH=$PATH:/usr/local/go/bin

对 profile 文件的修改一般不会立刻生效。为了让修改立刻生效,可以直接运行上述脚本文件,如 source $HOME/.profile。

3.打开终端,输入下面的命令,验证 Go 是否已安装完成

go version

4.确认上述命令输出已安装的 Go 版本号

Mac (下载 go1.19.4.darwin-amd64.pkg)

1.打开刚刚下载的安装包,根据提示进行安装

安装包会把 Go 安装在 /usr/local/go 目录,然后会把 /usr/local/go/bin 目录添加到环境变量 PATH 中。你可以重启任何已经打开的终端,使刚刚环境变量的修改生效。

2.打开终端,输入下面的命令,验证 Go 是否已安装完成

go version

3.确认上述命令输出已安装的 Go 版本号

Windows (下载 go1.19.4.windows-amd64.msi)

1.打开刚刚下载的 MSI 文件,根据提示进行安装

默认情况下,安装器会把 Go 安装到 Program Files 或 Program Files (x86) 目录下。你可以根据需要调整安装位置。安装完成后,需要关闭终端再重新打开,使刚刚安装器对环境变量的修改生效。

2.验证 Go 已安装完成

  1. 点击开始菜单
  2. 在菜单搜索框里,输入 cmd,按下 Enter 键
  3. 终端打开后,输入下面的命令
go version

3.确认上述命令输出已安装的 Go 版本号

1.2 运行 Hello World

写 Hello World 代码

1.打开 Terminal,进入用户主目录

Linux/Mac

cd

Windows

cd %HOMEPATH%

2.创建 hello 目录,存放你的第一个 Go 源代码文件

mkdir hello
cd hello

3.为你的代码开启依赖管理

当你的代码 import 其他模块提供的包(package)时,是通过你自己的代码模块来管理这些依赖的。go.mod 文件定义了你自己的代码模块,并且可以管理那些包含被导入的包的模块。go.mod 文件和你的代码一起,包含在你的代码库中。

要通过创建 g.mod 文件来开启依赖管理,可以运行 go mod init 命令,并提供模块名称(module name),模块名称也是模块的路径(module path)。

在实际的开发过程中,模块路径一般就是代码库的存放地址。例如,你的模块路径可能是 http://github.com/mymodule。如果你想公开你的模块给其他人使用,模块路径一定要是一个 Go tools 能够访问的地址。

为了简单,本示例使用 example/hello。

go mod init example/hello
# 输出
# go: creating new go.mod: module example/hello

4.在你的代码编辑器中,新建 hello.go 文件

5.复制下面的代码到 hello.go 文件并保存

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

这是你的 Go 代码,在代码中:

  • 声明了一个 main 包(包是一种组织相关函数和代码的方法,该包中的文件都存放在相同的目录里)
  • 导入 fmt 包,其中包含文本格式化输出函数,可以输出到控制台。这个包是标准库(standard library)中的包,标准库已经和 Go 一起安装到了你的电脑中。
  • 实现一个 main 函数,并输出一段消息到控制台。当你运行 main 包时,默认会从 main 方法开始执行。

6.运行代码

go run .
# 输出
# Hello, World!

go run 是运行代码的命令,通过下面的命令可以查看 go 命令列表:

go help

调用其他模块中的代码

当可能已经有别人实现了你要的功能时,你可以尝试检索一下可以在自己的代码中使用的包。

1.通过其他模块的函数让你的输出消息更有趣 1.访问 pkg.go.dev,然后搜索 "quote" 包 2.在搜索结果中找到并点击 rsc.io/quote/v4 包 3.在文档(Documentation)部分,索引(Index)下方,注意下你可以使用的函数列表。你会使用到其中的 Go 函数。 4.在页面上方,可以看到 quote 包

你可以通过 pkg.go.dev 网站检索已经公开发布的模块和包,然后在自己的代码中使用其中的函数。包会和模块一起公开发布,如 http://rsc.io/quote/v4,别人可以直接使用。模块会随着新版本的发布而改进,你可以更新代码使用新版本的模块。

2.在代码中导入 http://rsc.io/quote/v4 包,并调用它的 Go 函数。

按照下面的 diff 提示修改你的代码:

package main

import "fmt"

+ import "rsc.io/quote/v4"
+
func main() {
-    fmt.Println("Hello, World!")
+    fmt.Println(quote.Go())
}

3.添加新的模块依赖

Go 会在 go.mod 中声明 quote 模块为一个依赖,并创建 go.sum 文件。

go mod tidy
# 输出
# go: finding module for package rsc.io/quote/v4
# go: downloading rsc.io/quote/v4 v4.0.1
# go: found rsc.io/quote/v4 in rsc.io/quote/v4 v4.0.1
# go: downloading rsc.io/sampler v1.3.0
# go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c

4.运行代码查看 quote.Go 输出的消息

go run .
# 输出
# Don't communicate by sharing memory, share memory by communicating.

注意看,你的代码调用了 Go 函数,并且输出了一条关于通信的充满智慧的消息。

当你运行 go mod tidy 时,他定位并下载了 http://rsc.io/quote/v4 模块,其中包含了你导入的包。默认下载最新的版本 -- v4.0.1。

1.3 安装 Git

如果要克隆 GoChat 代码库,Git 工具安装和使用可参考 Pro Git 这本书。

Git 安装完成后,需要进行简单配置:

  • 配置 user.name
git config --global user.name "your-user-name"

# 确认已设置成功
git config --global user.name
# 输出
# your-user-name
  • 配置 user.email
git config --global user.email "your-email"

# 确认已设置成功
git config --global user.email
# 输出
# your-email

第二章 创建开源 GoChat 项目

这一章节,会在 Github 上创建一个 Public 项目 GoChat,并实现一个最简单版本的客户端与服务端通信程序,你可以学习到:

前置条件

  • 已安装 Git 工具,可克隆项目代码同步学习
  • 切换到 tag v0.1 git checkout v0.1

2.1 创建项目

打开 Github,新建一个 Public 项目 GoChat

创建工作目录 gowork

mkdir ~/gowork
cd ~/gowork

克隆空项目

git clone git@github.com:huoyijie/GoChat.git

初始化 Go 模块

go mod init github.com/huoyijie/GoChat
# 输出
# go: creating new go.mod: module github.com/huoyijie/GoChat

为了更方便组织和重用代码,项目主要由 server、client 和 lib 包组成。server 和 client 分别存放服务端和客户端可执行程序代码,lib 是服务和客户端可重用的工具包。创建服务与客户端 main 程序入口文件 main.go,以及 lib 工具文件 utils.go

mkdir server
echo "package main" > server/main.go

mkdir client
echo "package main" > client/main.go

mkdir lib
echo "package lib" > lib/utils.go
  1. server/main.go
package main

2. client/main.go

package main

3. lib/utils.go

package lib

看一下项目目录结构

GoChat
├── client
│   └── main.go
├── go.mod
├── lib
│   └── utils.go
├── LICENSE
├── README.md
└── server
    └── main.go

下一节实现 GoChat v0.1 版本,实现客户端与服务端的连接和通信。

2.2 实现 Server v0.1

// server/main.go
package main

import (
    "fmt"
    "net"

    "github.com/huoyijie/GoChat/lib"
)

func main() {
    // 启动单独协程,监听 ctrl+c 或 kill 信号,收到信号结束进程
    go lib.SignalHandler()

    // tcp 监听地址 0.0.0.0:8888
    addr := ":8888"

    // tcp 监听
    ln, err := net.Listen("tcp", addr)

    // tcp 监听遇到错误退出进程
    lib.FatalNotNil(err)
    // 输出日志
    lib.LogMessage("Listening on", addr)

    // 循环接受客户端连接
    for {
        // 每当有客户端连接时,ln.Accept 会返回新的连接 conn
        conn, err := ln.Accept()
        // 如果接受的新连接遇到错误,则退出进程
        lib.FatalNotNil(err)

        // 通过 conn 向连接的另一侧发送消息
        fmt.Fprintf(conn, "Hello, World!\r\n")

        // 为每个新连接启动一个单独协程,该协程会读取另一侧发送的消息
        go lib.HandleConnection(conn)
    }
}

关键字 go 会开启一个协程 (goroutine),协程是由 Go 运行时管理的轻量级线程。上面的程序有两处使用了协程。

// 1.启动信号监听协程
go lib.SignalHandler()
// 2.启动连接处理协程
go lib.HandleConnection(conn)

关于协程的概念可以看 Effective Go: Goroutines。

协程的示例程序可以看 Tour: Goroutines 和 Go by Example: Goroutines。

2.3 实现 Client v0.1

package main

import (
    "fmt"
    "net"

    "github.com/huoyijie/GoChat/lib"
)

func main() {
    // 客户端进行 tcp 拨号,请求连接 127.0.0.1:8888
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    // 连接遇到错误则退出进程
    lib.FatalNotNil(err)

    // 连接服务端成功,启动单独协程处理另一侧发送过来的消息
    go lib.HandleConnection(conn)

    // 向服务端发送消息
    fmt.Fprintf(conn, "Hello, World!\r\n")

    // 阻塞主线程,直到收到 ctrl+c 或者 kill 信号,退出进程
    lib.SignalHandler()
}

上面的程序有一处使用了协程。

// 1.启动连接处理协程
go lib.HandleConnection(conn)

2.4 实现 Lib v0.1

package lib

import (
    "bufio"
    "log"
    "net"
    "os"
    "os/signal"
    "syscall"
)

// 如果 err != nil,输出错误日志并退出进程
func FatalNotNil(err error) {
    if err != nil {
        log.Fatal(err)
    }
}

// 如果 err != nil,输出错误日志
func LogNotNil(err error) {
    if err != nil {
        log.Println(err)
    }
}

// 输出消息到日志
func LogMessage(msg ...any) {
    log.Println(msg...)
}

// 输出连接建立与关闭消息到日志,包内私有方法,外部不能调用
func logConn() func() {
    log.Println("已连接")
    return func() {
        log.Println("已断开连接")
    }
}

// 接收连接另一侧发送的消息,输出消息到日志
func HandleConnection(conn net.Conn) {
    // 连接建立和断开时,分别输出日志
    defer logConn()()
    // 从当前方法返回时,关闭连接
    defer conn.Close()

    // 设置如何处理接收到的字节流,bufio.ScanLines 为逐行扫描的方式把字节流分割为消息流
    scanner := bufio.NewScanner(conn)
    scanner.Split(bufio.ScanLines)

    // 循环解析消息,每当解析出一条消息后,scan() 返回 true
    for scanner.Scan() {
        // 返回解析出的消息字节 slice
        bytes := scanner.Bytes()
        // 消息内容不为空,则输出到日志
        if len(bytes) > 0 {
            LogMessage(string(bytes))
        }
    }

    // 如果解析消息遇到错误,则输出错误到日志
    LogNotNil(scanner.Err())
}

// 信号监听处理器
func SignalHandler() {
    // 创建信号 channel
    sigchan := make(chan os.Signal, 1)

    // 注册要监听哪些信号
    signal.Notify(sigchan, os.Interrupt) // ctrl+c
    signal.Notify(sigchan, syscall.SIGTERM) // kill

    // 一直阻塞,直到收到信号,恢复执行并退出进程
    <-sigchan
    // 退出进程
    defer os.Exit(0)
}

关键字 defer 可以修饰函数调用,用来推迟该函数的执行,直到执行 defer 语句的上层函数返回。上方的程序有三处使用了 defer,如:

func HandleConnection(conn net.Conn) {
    // 连接建立和断开时,分别输出日志
    defer logConn()()
    // 从当前方法返回时,关闭连接
    defer conn.Close()

    // 省略...
}

首先执行 logConn(),立刻输出“已连接”并返回一个匿名函数,该匿名函数被 defer 修饰,推迟到 HandleConnection 函数返回时调用。同样地,conn.Close 被 defer 修饰,也推迟到 HandleConnection 函数返回时调用。被 defer 修饰的函数,按照 defer 语句出现顺序逆序执行。也就是说,在 HandleConnection 函数返回时,先执行 conn.Close(),再执行 logConn() 返回的匿名函数。

一般情况,不管使用资源的代码如何执行,资源在使用完成后必须释放,defer 是一种不常见但是非常有效的释放资源的方法。

关于 defer 可以看 Effective Go: Defer。

defer 示例可以看 Tour: Defer 和 Go by Example: Defer。

SignalHandler 函数中使用到了信道 (channel),信道是连接并发协程的管道,你可以在一个协程中向信道写入值,然后从另一个协程中读取值。通过 signal.Notify 向系统注册关心的信号,并提供 chan os.Signal 类型参数 sigchan,当信号出现时,会向 sigchan channel 写入信号值 (sigchan <- signal),此时 <-sigchan 可以读取到该值,并从阻塞状态中恢复。

关于 channel 可以看 Effective Go: Channels。

channel 示例可以看 Tour: Channels 和 Go by Example: Channels

2.5 运行 GoChat v0.1

打开 Terminal 运行 server

$ go run server/main.go 
2022/12/19 11:41:37 Listening on :8888

打开 Terminal 运行 client

$ go run client/main.go 
2022/12/19 11:41:46 已连接
2022/12/19 11:41:46 Hello, World!

此时,server 端输出

2022/12/19 11:41:37 Listening on :8888
2022/12/19 11:41:46 已连接
2022/12/19 11:41:46 Hello, World!
Hello, World!
ctrl+c

第三章 实现 GoChat v0.2 群聊

这一章节会从 GoChat v0.1 升级到 v0.2,实现客户端群聊。继续阅读前请切换代码到 tag v0.2 git checkout v0.2

3.1 实现 Server v0.2

package main

import (
    "fmt"
    "net"
    "sync"

    "github.com/bwmarrin/snowflake"
    "github.com/huoyijie/GoChat/lib"
)

// 封装客户端连接,增加 snowflake.ID
type socket struct {
    id   snowflake.ID
    conn net.Conn
}

// 存储当前所有客户端连接
var sockets = make(map[snowflake.ID]*socket)

// 多个协程并发读写 sockets 时,需要使用读写锁
var lock sync.RWMutex

// 写锁
func wSockets(wSockets func()) {
    lock.Lock()
    defer lock.Unlock()
    wSockets()
}

// 读锁
func rSockets(rSockets func()) {
    lock.RLock()
    defer lock.RUnlock()
    rSockets()
}

func main() {
    // 启动单独协程,监听 ctrl+c 或 kill 信号,收到信号结束进程
    go lib.SignalHandler()

    // tcp 监听地址 0.0.0.0:8888
    addr := ":8888"

    // tcp 监听
    ln, err := net.Listen("tcp", addr)

    // tcp 监听遇到错误退出进程
    lib.FatalNotNil(err)
    // 输出日志
    lib.LogMessage("Listening on", addr)

    // 创建 snowflake Node
    node, err := snowflake.NewNode(1)
    lib.FatalNotNil(err)

    // 循环接受客户端连接
    for {
        // 每当有客户端连接时,ln.Accept 会返回新的连接 conn
        conn, err := ln.Accept()
        // 如果接受的新连接遇到错误,则退出进程
        lib.FatalNotNil(err)

        // 生成新 ID
        id := node.Generate()
        // 保存新连接
        wSockets(func() {
            sockets[id] = &socket{id, conn}
        })

        // 为每个客户端启动一个协程,读取客户端发送的消息并转发
        go lib.HandleConnection(
            conn,
            id,
            func(msg string) {
                rSockets(func() {
                    for k, v := range sockets {
                        // 向其他所有客户端(除了自己)转发消息
                        if k != id {
                            fmt.Fprintf(v.conn, "%d:%s\r\n", id, msg)
                        }
                    }
                })
            },
            func() {
                // 从当前方法返回时,关闭连接
                conn.Close()
                // 删除连接
                wSockets(func() {
                    delete(sockets, id)
                })
            })
    }
}

server 要把某个 client 发送的消息转发给其他所有 client,需要存储所有的连接,同时给所有连接分配一个 ID,以区分不同的 client。

// 封装客户端连接,增加 snowflake.ID
type socket struct {
    id   snowflake.ID
    conn net.Conn
}

ID 采用 Twitter snowflake 算法生成,需要导入 bwmarrin/snowflake 库

import (
    "fmt"
    "net"
    "sync"

+    "github.com/bwmarrin/snowflake"
    "github.com/huoyijie/GoChat/lib"
)

导入外部库后,需要进入项目根目录 GoChat,然后执行 go mod tidy,会自动下载 bwmarrin/snowflake 库。

// 存储当前所有客户端连接
var sockets = make(map[snowflake.ID]*socket)

// 多个协程并发读写 sockets 时,需要使用读写锁
var lock sync.RWMutex

所有封装好的连接,放入 map 类型变量 sockets 里。当有新连接建立时写入 map,有连接断开时从 map 中删除,转发消息时需要遍历读取所有客户端连接。这些对 sockets 变量的读写操作发生在不同的协程里面,为了避免多个协程因并发读写 sockets 共享内存而造成错误,需要读写锁进行同步。

// 写锁
func wSockets(wSockets func()) {
    lock.Lock()
    defer lock.Unlock()
    wSockets()
}

// 读锁
func rSockets(rSockets func()) {
    lock.RLock()
    defer lock.RUnlock()
    rSockets()
}

上面分别是写锁定和读锁定方法,写锁定后,禁止其他所有对 sockets 变量的读写操作。读锁定后,禁止其他所有对 sockets 变量的写操作,但允许其他所有读操作。锁定后要进行的操作,通过 func 类型参数传入。

1.新连接建立后要保存

// 通过 snowflake 算法生成新 ID
id := node.Generate()
// 保存新连接
wSockets(func() {
    sockets[id] = &socket{id, conn}
})

2.断开连接后要删除

// 删除连接
wSockets(func() {
    delete(sockets, id)
})

3.转发客户端消息

rSockets(func() {
    for k, v := range sockets {
        // 向其他所有客户端(除了自己)转发消息
        if k != id {
            fmt.Fprintf(v.conn, "%d:%s\r\n", id, msg)
        }
    }
})

lib.HandleConnection 函数现在需要传入 2 个 func 类型参数,分别是 handleMsg 和 close。前者是消息处理回调函数,后者是连接关闭回调函数。

3.2 实现 Client v0.2

package main

import (
    "fmt"
    "net"

    "github.com/bwmarrin/snowflake"
    "github.com/huoyijie/GoChat/lib"
)

func main() {
    // 启动单独协程,监听 ctrl+c 或 kill 信号,收到信号结束进程
    go lib.SignalHandler()

    // 客户端进行 tcp 拨号,请求连接 127.0.0.1:8888
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    // 连接遇到错误则退出进程
    lib.FatalNotNil(err)

    // id 由服务器生成,暂时未发给客户端
    var id snowflake.ID
    // 连接成功后启动协程输出服务器的转发消息
    go lib.HandleConnection(
        conn,
        id,
        func(msg string) {
            lib.PrintMessage(msg)
        },
        func() {
            // 从当前方法返回时,关闭连接
            conn.Close()
        })

    var input string
    for {
        // 读取用户输入消息
        fmt.Scanf("%s", &input)
        // 向服务端发送消息
        fmt.Fprintf(conn, "%s\r\n", input)
    }
}

client 要循环读取用户输入,然后作为消息发送给 server

var input string
for {
    // 读取用户输入消息
    fmt.Scanf("%s", &input)
    // 向服务端发送消息
    fmt.Fprintf(conn, "%s\r\n", input)
}

3.3 实现 Lib v0.2

package lib

import (
    "bufio"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"
    "syscall"

    "github.com/bwmarrin/snowflake"
)

// 如果 err != nil,输出错误日志并退出进程
func FatalNotNil(err error) {
    if err != nil {
        log.Fatal(err)
    }
}

// 如果 err != nil,输出错误日志
func LogNotNil(err error) {
    if err != nil {
        log.Println(err)
    }
}

// 输出日志
func LogMessage(msg ...any) {
    log.Println(msg...)
}

// 打印消息
func PrintMessage(msg ...any) {
    fmt.Println(msg...)
}

// 输出连接建立与关闭消息到日志,包内私有方法,外部不能调用
func logConn(id snowflake.ID) func() {
    fmt.Fprintf(os.Stdout, "%d: 已连接\n", id)
    return func() {
        fmt.Fprintf(os.Stdout, "%d: 已断开连接\n", id)
    }
}

// 接收连接另一侧发送的消息,输出消息到日志
func HandleConnection(conn net.Conn, id snowflake.ID, handleMsg func(string), close func()) {
    // 连接建立和断开时,分别输出日志
    defer logConn(id)()

    // 从当前方法返回后,断开连接,清理资源等
    defer close()

    // 设置如何处理接收到的字节流,bufio.ScanLines 为逐行扫描的方式把字节流分割为消息流
    scanner := bufio.NewScanner(conn)
    scanner.Split(bufio.ScanLines)

    // 循环解析消息,每当解析出一条消息后,scan() 返回 true
    for scanner.Scan() {
        // 返回解析出的消息字节 slice
        bytes := scanner.Bytes()
        // 消息内容不为空,则输出到日志
        if len(bytes) > 0 {
            handleMsg(string(bytes))
        }
    }

    // 如果解析消息遇到错误,则输出错误到日志
    LogNotNil(scanner.Err())
}

// 信号监听处理器
func SignalHandler() {
    // 创建信号 channel
    sigchan := make(chan os.Signal, 1)

    // 注册要监听哪些信号
    signal.Notify(sigchan, os.Interrupt)    // ctrl+c
    signal.Notify(sigchan, syscall.SIGTERM) // kill

    // 一直阻塞,直到收到信号,恢复执行并退出进程
    <-sigchan
    // 退出进程
    defer os.Exit(0)
}

HandleConnection 函数增加了 id、handleMsg 和 close 三个参数。

func HandleConnection(conn net.Conn, id snowflake.ID, handleMsg func(string), close func())

3.4 运行 GoChat v0.2

打开 Terminal 运行 server

# server
$ go run server/main.go 
2022/12/19 11:08:07 Listening on :8888

打开 Terminal 运行 client 1

# client 1
$ go run client/main.go 
0: 已连接

此时,server 端输出

2022/12/19 11:08:07 Listening on :8888
1604674940534001664: 已连接

打开 Terminal 运行 client 2

# client 2
$ go run client/main.go 
0: 已连接

此时,server 端输出

2022/12/19 11:08:07 Listening on :8888
1604674940534001664: 已连接
1604674966949728256: 已连接

client 发送消息

现在共有 1 个 server 进程和 2 个 client 进程。所有已连接到 server 的 client 可以在 Terminal 窗口输入消息,按下回车键发送消息。其他 client (除了自己) 都可以收到消息。如 client 1 发送 hello:

# client 1
0: 已连接
hello # 输入 hello

此时,client 2 会收到消息

# client 2
0: 已连接
1604674940534001664:hello

同样的,client 2 发送的消息,client 1 也可以收到。也支持打开更多的 client 窗口并同时参与群聊。

ctrl+c

第四章 实现 GoChat v0.3

这一章节会从 GoChat v0.2 升级到 v0.3,实现最基础的功能:

运行 GoChat v0.3:

  • 服务器
  • 客户端 (huoyijie)
  • 客户端 (jack)

后面会详细介绍服务器与客户端的技术实现细节。继续阅读前请切换代码到 tag v0.3 git checkout v0.3

4.1 Server v0.3 组件图

由上图可以看出,服务器程序在逻辑上主要由存储(storage)、业务逻辑(biz)和程序入口(main)三个部分组成。

数据存储

storage 模块负责存储用户帐号和消息数据,数据会写入 sqlite 存储文件。其他程序模块可通过 storage 模块读写 Account 和 Message 表。Message 表只是临时存储用户的未读消息,在该用户登录前会一直存储在表中,当用户登录后,服务器会自动把用户未读消息发给客户端,并从表中删除。

业务逻辑

biz 模块主要负责处理客户端请求并返回响应数据,主要由注册(signup)、登录(signin)、验证 token(val-token)、获取用户列表(users)、接收消息(recv-msg)、登出(signout)和 ping组成。所有的以上具体 struct 都实现了 biz_i 接口,并且都嵌入了 base_t 结构体,而 base_t 内部封装了 poster 和 push 组件,分别用来给客户端发送响应(response)数据包和推送(push)数据包。

其中 signup、signin 和 val-token 三个组件涉及到登录后 token 的生成与验证,需要调用 auth 组件。

程序入口

main 模块是程序执行入口,第一步会注册信号监听处理器(signalHandler),当进程收到中断(Interupt)或 Kill 信号时可以清理资源并退出进程。

第二步会启动独立协程 handlePush,该协程会收集并保存所有已建立的客户端连接,并会接收来自其他模块的 push。

第三步进入 for 循环,不断接受(Accept)新连接,并为新连接启动独立协程 handleConn,而主线程会阻塞在 Accept 调用上直到有新的连接进来。

现在,我们把目光投向刚刚为新连接启动的协程 handleConn。为了能够独立的接收与发送数据包,协程 handleConn 内部会再启动一个独立协程 recvFrom,接收来自客户端的请求数据包,并调用 biz 模块的不同组件实现具体的业务逻辑。然后协程 handleConn 会调用 sendTo 函数并一直阻塞直到连接断开。sendTo 函数内部会监听 3 个通道(channel)。

  • biz 模块会通过 poster 发送响应数据包,该数据包会被发往第 1 个通道,然后 sendTo 会把该响应数据包发往客户端。
  • biz 模块还会通过 push 组件发送推送数据到协程 handlePush,而 handlePush 协程通过通道收到该推送后,会转发给 sandTo 监听的第 2 个通道,最后由 sandTo 把该推送数据包发往客户端。
  • sendTo 中监听的第 3 个通道是一个计时器(Ticker),每隔 100ms 会想通道写入当前时间。可以实现每隔 100ms 读取 storage,检查当前登录用户的未读消息,并把未读消息数据包发往客户端。

4.2 Client v0.3 组件图

由上图可以看出,客户端程序在逻辑上主要由存储(storage)、界面(ui)和程序入口(main)三个部分组成。

数据存储

storage 模块负责存储用户登录凭证(token)、消息(message)和推送(push)等数据,数据会写入 sqlite 存储文件。其他程序模块可通过 storage 模块读写 KeyValue、Message 和 Push 表。KeyValue 是通用的键值对表,可以存储任意键值对,目前主要用来存储 token。Message 是当前用户收到的所有未读消息表,消息可能来自多个不同的人,聊天对话框(chat)界面会从该表中读取对方发送给自己的消息,并显示在界面上。Push 表是当前用户收到的未读推送,目前主要用来接收用户上下线事件推送,以实时更新用户列表界面上的用户上下线状态。

程序界面

程序界面基于 bubbletea (A powerful little TUI framework) 实现。

主要有 5 个交互界面,分别是登录(signup)、注册(signin)、首页(home)、用户列表(users)和聊天对话框(chat)。它们都嵌入了 base_t 结构体,base_t 结构体内部封装了向服务器发送数据包的 poster 和读写存储的 storage。其中,signup 和 signin 是非常相似的表单提交界面,除了极少的差异,拥有几乎一样的代码,所以她们共同嵌入了 form_t 结构体,form_t 实现了注册和登录通用的代码逻辑,具体的差异在 signup 和 signin 中分别单独实现。

程序入口

main 模块是程序执行入口,第一步会启动独立协程 randerUI,该协程负责渲染界面、用户交互处理与页面跳转等。第二步进入处理连接断开后自动重连的 for 循环,在循环内调用 connect 函数,该函数封装了基于指数退让算法实现的连接服务器代码。连接成功后,启动独立协程 recvFrom 接收来自服务器的数据包。然后主线程会调用 sendTo 函数并一直阻塞,直到连接断开或退出进程。

  • recvFrom 协程会接收来自服务器的数据包,主要有消息(msg)、推送(push)、pong 和请求响应数据包。收到 msg 和 push 后会通过 storage 写入表中,收到 pong 暂时忽略。
  • sendTo 函数会定期发送 ping 数据包,然后会监听通道,把来自 poster 的请求数据包 (request_t) 发送到服务器。当 recvFrom 协程收到响应数据包时会通过通道把 response_t 转发给 sendTo 函数,sendTo 函数再返回给 poster。用户界面交互过程中,可用过 poster 发送请求和接收响应数据。

4.3 Lib v0.3 组件图

由上图可以看出,Lib 主要由 protobuf 定义(packet.proto) 和 commons 通用组件组成。客户端与服务器通信采用二进制协议,并采用 protobuf 序列化/反序列化协议。

通信协议

+--------+----------------------+
| length |     packet data      |
+--------+----------------------+

Protobuf 定义

syntax = "proto3";

package lib;

option go_package = "github.com/huoyijie/GoChat/lib";

enum PackKind {
  // Server
  PONG    =  0;
  ERR     =  1;
  RES     =  2;
  PUSH    =  3;
  // All
  MSG     =  4;
  // Client
  PING    =  5;
  SIGNUP  =  6;
  SIGNIN  =  7;
  TOKEN   =  8;
  SIGNOUT =  9;
  USERS   = 10;
}

message Packet {
  PackKind kind = 1;
  uint64   id   = 2;
  bytes    data = 3;
}

message Ping{
  bytes payload = 1;
}

message Pong{
  bytes payload = 1;
}

message Auth {
  string username = 1;
  bytes  passhash = 2;
}

message Signup {
  Auth auth = 1;
}

message Signin {
  Auth auth = 1;
}

message Token {
  bytes token = 1;
}

message TokenRes {
  int32  code     = 1;
  uint64 id       = 2;
  string username = 3;
  bytes  token    = 4;
}

message Signout {}

message SignoutRes {
  int32 code  = 1;
}

message User {
  string username = 1;
  bool   online   = 2;
}

message Users {}

message UsersRes {
  int32         code  = 1;
  repeated User users = 2;
}

enum MsgKind {
  TEXT = 0;
}

message Msg {
  int64   id   = 1;
  MsgKind kind = 2;
  string  from = 3;
  string  to   = 4;
  bytes   data = 5;
}

message ErrRes {
  int32 code  = 1;
}

enum PushKind {
  ONLINE  = 0;
}

message Push {
  PushKind kind = 1;
  bytes    data = 2;
}

enum OnlineKind {
  ON  = 0;
  OFF = 1;
}

message Online {
  OnlineKind kind     = 1;
  string     username = 2;
}

Commons

主要是封装一些工具方法和错误码定义,如编解码、加解密等。

4.4 GoChat v0.3 顺序图

该教程也会同步发在我的网站上,感兴趣的同学可以来看一下哈!