教程:使用Go和Gin开发RESTful API

本文翻译自《Tutorial: Developing a RESTful API with Go and Gin》。

目录

先决条件

设计API端点

为代码创建文件夹

创建数据

编写处理程序以返回所有条目

编写处理程序以添加一个新条目

编写处理程序以返回一个特定条目

结论

全部代码

本教程介绍了使用Go和Web开发框架Gin编写RESTful web服务API的基本知识。

如果你对Go及其工具有基本的熟悉,你将从本教程中获得最大的收获。如果这是你第一次接触Go,请先参阅教程:Go快速入门

Gin简化了许多与构建web应用程序(包括web服务)相关的编程任务。在本教程中,你将使用Gin来路由请求、检索请求详细信息,并为发送JSON响应。

在本教程中,你将构建一个具有两个端点的RESTful API服务器。你的示例项目将是一个关于老式爵士乐唱片的数据存储库。

本教程包括以下部分:

1 设计API端点。

2 为代码创建一个文件夹。

3 创建数据。

4 编写一个处理程序以返回所有条目。

5 编写一个处理程序来添加一个新条目。

6 编写一个处理程序以返回一个特定条目。

先决条件

  • 安装Go 1.16或更高版本。有关安装说明,请参阅安装Go
  • 用于编辑代码的工具。你拥有的任何文本编辑器都可以正常工作。
  • 一种命令行终端。Go在Linux和Mac上使用任何终端都能很好地工作,以及Windows中的PowerShell或CMD。
  • curl程序。在Linux和Mac上,应该已经安装了。在Windows上,它包含在Windows 10 Insider版本17063及更高版本中。对于早期的Windows版本,你可能需要安装它。有关更多信息,请参阅Tar and Curl Come to Windows

设计API端点

你将建立一个API,提供对一家出售老式黑胶唱片的商店的访问。因此,你需要提供API端点,用户可以通过客户端访问这些端点来获取和添加相册。

在开发API时,通常从设计端点开始。如果端点易于理解,将方便API的用户使用。

以下是你将在本教程中创建的API端点:

/albums

  • GET–获取所有相册的列表,以JSON形式返回。
  • POST–以JSON形式发送的请求数据,添加一个新相册。

/albums/:id

  • GET–通过相册ID获取相册,并以JSON形式返回相册数据。

为代码创建文件夹

首先,为你要编写的代码创建一个项目。

1 打开命令行终端并转到家目录。 在Linux或Mac上:

$ cd

在Windows上:

C:\> cd %HOMEPATH%

2 使用命令行终端,为代码创建一个名为web-service-gin的目录:

$ mkdir web-service-gin
$ cd web-service-gin

3 创建一个可以在其中管理依赖关系的模块。

运行go mod init命令,为其提供代码所在模块的路径:

$ go mod init example/web-service-gin
go: creating new go.mod: module example/web-service-gin

此命令创建一个go.mod文件,你添加的依赖项将列在该文件中进行跟踪。有关使用路径命名模块的详细信息,请参阅管理依赖关系

接下来,你将设计用于处理数据的数据结构。

创建数据

为了简化教程,你将把数据存储在内存中。更典型的API将与数据库交互。

请注意,将数据存储在内存中意味着每次停止服务器时,相册相关数据都会丢失,然后在启动服务器时重新创建。

编写代码

1 使用文本编辑器,在web-service-gin目录中创建一个名为main.go的文件。你将在该文件中编写Go代码。

2 在文件顶部的main.go中,粘贴以下包声明。

package main

独立可运行的程序(与库相对)始终位于main包中。

3 在包声明下面,粘贴album结构体的以下声明。你将使用它将相册数据存储在内存中。

代码中的结构体标记(json:"artist"等)指定在将结构体的内容序列化为JSON时,字段的名称如何转换。如果没有它们,JSON将使用结构体的大写的字段名——这种风格在JSON中并不常见。

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

在刚添加的结构体声明下面,粘贴下面的album结构体片段,其中包含将启动你的项目的数据。

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

接下来,你将编写代码来实现你的第一个API端点。

编写处理程序以返回所有条目

当客户端在GET /albums上发出请求时,你希望以JSON的形式返回所有的相册信息。

为此,你将编写以下代码:

  • 响应的逻辑
  • 将请求路径映射到响应的逻辑

但你首先要添加依赖项,然后添加依赖于它们的代码。

编写代码

1 在上一节中添加的结构体代码下面,粘贴以下代码以获得相册信息的列表。

这个getAlbums函数从相册结构体的切片albums创建JSON,并将JSON写入响应。

// getAlbums以JSON格式的数据响应一个相册信息的列表。
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

在此代码中,你:

  • 编写了一个接受gin.Context参数的getAlbums函数。请注意,你可以给这个函数起任何名称——Gin和Go都不需要特定的函数名称格式。

gin.Context是Gin最重要的组成部分。它携带请求的详细信息、验证和序列化JSON等。(尽管名称相似,但这与Go内置的context包不同。)

该函数的第一个参数是要发送到客户端的HTTP状态代码。在这里,你传递net/http包的StatusOK常量,表示200 OK这一HTTP状态代码。

请注意,你可以将Context.IndetedJSON函数替换为Context.JSON函数,以发送更紧凑的JSON数据。在实践中,缩进形式的JSON数据在调试时更具可读性,而且也不会比紧凑的JSON数据大很多。

2 在main.go顶部附近的albums切片声明下方,粘贴下面的代码,将处函数分配给API端点。

这设置了一个关联,getAlbums函数处理对/albums路径的请求。

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • 使用Default函数初始化Gin路由器。
  • 使用GET函数将GET HTTP方法和/albums路径与处理函数相关联。

请注意,你传递的是getAlbums函数的名称。这与传递函数的结果不同,传递函数的结果是传递getAlbums()(注意括号)。

  • 使用Run函数将路由器关联到一个http服务器并启动服务器。

3 在main.go的顶部,就在包声明的下方,导入用到的包。

第一行代码应该如下所示:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

4 保存main.go文件。

运行代码

1 引入Gin模块作为依赖项。 在命令行中,使用go get添加github.com/gin-gonic/gin模块作为你的example/web-service-gin模块的依赖项。使用句点参数表示“下载当前目录中代码的所有依赖项”:

$ go get .
go get: added github.com/gin-gonic/gin v1.7.2

Go解析并下载依赖项,以满足你在上一步中添加的import声明。

2 在包含main.go文件的目录的命令行中,运行代码。使用句点参数表示“在当前目录中运行代码”:

$ go run .

一旦代码运行,你就有了一个正在运行的HTTP服务器,可以向其发送请求。

3 在一个新的命令行窗口中,使用curl工具向正在运行的web服务发出请求。

$ curl http://localhost:8080/albums

将会返回以下JSON格式的数据:

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        }
]

你已经启动了一个API!在下一节中,你将使用代码创建另一个API端点,以处理添加一个信息条目的POST请求。

编写处理程序以添加一个新条目

当客户端在/albums上发出POST请求时,你希望将请求正文中描述的相册信息添加到现有的数据中。

为此,你将编写以下内容:

  • 将一条新相册的信息添加到现有列表里的一段代码。
  • 将POST请求路由到你的上述代码的一段代码。

编写代码

1 添加代码以将相册信息数据添加到相册列表中。

import语句之后的某个位置,粘贴以下代码。(文件的末尾是粘贴这段代码的好位置,但Go并没有强制函数的声明顺序。)

// postAlbums函数从请求体中获取JSON数据添加一条相册信息数据。
func postAlbums(c *gin.Context) {
    var newAlbum album

    // 调用BindJSON函数把接收到的JSON数据转换为newAlbum。
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // 把newAlbum添加到albums列表里。
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

在此代码中,你:

  • 使用Context.BindJSON函数将请求正文绑定到newAlbum结构体变量。
  • 将从JSON数据转换得到的album结构体变量添加到albums切片。
  • 在响应中添加一个201状态代码,以及表示你成功创建一条新相册信息数据。

2 更改main函数,使用router.POST函数添加路由,如下所示:

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • /albums路径上的POST方法与postAlbums函数相关联。

使用Gin,你可以将处理程序与HTTP方法和API路径相关联。通过这种方式,你可以根据客户端使用的HTTP方法将发送到某个API路径的请求单独路由到某个处理函数。

运行代码

1 如果服务器仍在运行,请停止它。

2 在包含main.go的目录的命令行中,运行代码:

$ go run .

3 从另一个命令行窗口,使用curl工具向正在运行的web服务发出请求:

$ curl http://localhost:8080/albums \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"id": "4","title": "The Modern Sound of Betty Carter","artist": "Betty Carter","price": 49.99}'

该命令运行后应该会显示添加的相册信息的JSON数据和HTTP响应状态数据:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Wed, 02 Jun 2021 00:34:12 GMT
Content-Length: 116

{
    "id": "4",
    "title": "The Modern Sound of Betty Carter",
    "artist": "Betty Carter",
    "price": 49.99
}

4 与上一节一样,使用curl工具检索相册信息的完整列表,你可以使用该列表来确认是否添加了一个新相册的信息:

$ curl http://localhost:8080/albums \
    --header "Content-Type: application/json" \
    --request "GET"

该命令会显示相册信息的列表:

[
        {
                "id": "1",
                "title": "Blue Train",
                "artist": "John Coltrane",
                "price": 56.99
        },
        {
                "id": "2",
                "title": "Jeru",
                "artist": "Gerry Mulligan",
                "price": 17.99
        },
        {
                "id": "3",
                "title": "Sarah Vaughan and Clifford Brown",
                "artist": "Sarah Vaughan",
                "price": 39.99
        },
        {
                "id": "4",
                "title": "The Modern Sound of Betty Carter",
                "artist": "Betty Carter",
                "price": 49.99
        }
]

在下一节中,你将添加代码来处理对特定一个相册信息的GET请求。

编写处理程序以返回一个特定条目

当客户端请求GET /albums/[id]时,你希望返回ID值与id路径参数匹配的相册的信息。

为此,你将:

  • 添加代码以检索请求的相册的信息数据。
  • 将API路径映射到上述代码。

编写代码

1 在上一节中添加的postAlbums函数下面,粘贴以下代码以检索特定的相册。

getAlbumByID函数将提取请求路径中的id,然后查找匹配的相册的信息。

// getAlbumByID函数使用客户端发送过来的id参数定位并返回相册数据作为响应。
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // 循环遍历albums列表,查找匹配id参数的album。
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

在这段代码中,你:

  • 使用Context.Param从URL中检索id路径参数。将此处理程序映射到API路径时,将在API路径中包含id参数的占位符。
  • 循环遍历albums切片中的album结构体变量,查找ID字段值与id参数值匹配的那个结构体。如果找到了,则将该album序列化为JSON,并将其作为响应返回,并返回一个200 OK的HTTP状态码。

如上所述,真实世界的服务可能会使用数据库查询来执行此查找。

2 最后,更改main函数,添加一个router.GET路由到getAlbumByID函数,其中的API路径是/albums/:id,如以下所示:

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

在此代码中,你:

  • /albums/:id这个API路径与getAlbumByID函数相关联。在Gin中,API路径中前面的冒号表示这个条目是一个路径参数。

运行代码

1 停止运行之前的程序。

2 在包含main.go的目录的命令行中,运行代码:

$ go run .

3 从另一个命令行窗口,使用curl工具向正在运行的web服务发出请求:

$ curl http://localhost:8080/albums/2

该命令执行后应该显示你给出ID的相册信息的JSON数据:

{
        "id": "2",
        "title": "Jeru",
        "artist": "Gerry Mulligan",
        "price": 17.99
}

如果找不到对应的相册信息,你将获得带有错误消息的JSON。

结论

恭喜你刚刚使用Go和Gin编写了一个简单的RESTful web服务。

建议的下一个主题:

全部代码

本节包含使用本教程构建的应用程序的全部代码:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// album represents data about a record album.
type album struct {
    ID     string  `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

// albums slice to seed record album data.
var albums = []album{
    {ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
    {ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
    {ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

func main() {
    router := gin.Default()
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)

    router.Run("localhost:8080")
}

// getAlbums responds with the list of all albums as JSON.
func getAlbums(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, albums)
}

// postAlbums adds an album from JSON received in the request body.
func postAlbums(c *gin.Context) {
    var newAlbum album

    // Call BindJSON to bind the received JSON to
    // newAlbum.
    if err := c.BindJSON(&newAlbum); err != nil {
        return
    }

    // Add the new album to the slice.
    albums = append(albums, newAlbum)
    c.IndentedJSON(http.StatusCreated, newAlbum)
}

// getAlbumByID locates the album whose ID value matches the id
// parameter sent by the client, then returns that album as a response.
func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    // Loop through the list of albums, looking for
    // an album whose ID value matches the parameter.
    for _, a := range albums {
        if a.ID == id {
            c.IndentedJSON(http.StatusOK, a)
            return
        }
    }
    c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

Go:优雅成长的代码

本文翻译自《Go: code that grows with grace》,是个PPT。

Andrew Gerrand

Google Sydney

1 Go:优雅成长的代码

Andrew Gerrand

Google Sydney

2 视频

本次演讲的视频于2012年11月在瑞典马尔默的Øredev录制。

在Vimeo上观看演讲

3 Go

你可能听说过Go。

这是我最喜欢的语言。我想你也会喜欢的。

4 什么是Go?

一个开源(BSD 许可)项目:

  • 语言规范,
  • 小型运行时(垃圾收集器、调度器等),
  • 两个编译器(gc和gccgo),
  • “包括电池”标准库,
  • 工具(构建、获取、测试、文档、配置文件、格式),
  • 文档。

截至2012年9月,我们有300多名贡献者。

5 Go是关于组合的

Go是面向对象的,但不是以通常的方式实现。

  • 没有类(可以在任何类型上声明方法)
  • 没有子类继承
  • 隐式满足接口(结构类型)

结果:通过小接口连接起来的多个简单部件。

6 Go是关于并发的

Go提供了类似CSP的并发原语。

  • 轻量级线程(协程(goroutines))
  • 类型化线程安全通信和同步(通道(channels))

结果:可理解的并发代码。

7 Go是关于地鼠的

8 核心价值

Go是关于组合、并发和地鼠的。

记在脑子里。

9 Hello, go

package main

import "fmt"

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

10 Hello, net

package main

import (
    "fmt"
    "log"
    "net"
)

const listenAddr = "localhost:4000"

func main() {
    l, err := net.Listen("tcp", listenAddr)
    if err != nil {
        log.Fatal(err)
    }
    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        fmt.Fprintln(c, "Hello!")
        c.Close()
    }
}

11 接口

嘿内托!我们只是使用Fprintln写入网络连接。

这是因为Fprintln写入io.Writer,而net.Conn实现了io.Writer接口。

        fmt.Fprintln(c, "Hello!")

func Fprintln(w io.Writer, a ...interface{}) (n int, err error)

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    // ... 省略了一些额外函数 ...
}

12 回声服务器

package main

import (
    "io"
    "log"
    "net"
)

const listenAddr = "localhost:4000"

func main() {
    l, err := net.Listen("tcp", listenAddr)
    if err != nil {
        log.Fatal(err)
    }
    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        io.Copy(c, c)
    }
}

13 深入了解io.Copy

        io.Copy(c, c)

// 将副本从src复制到dst,直到在src上达到EOF或发生错误。它返回复制的字节数和复制时遇到的第一个错误(如果有)。
func Copy(dst Writer, src Reader) (written int64, err error)

type Conn interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
    Close() error
    // ... 省略了一些额外函数 ...
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Reader interface {
    Read(p []byte) (n int, err error)
}

14 协程(Goroutine)

Goroutine是由Go运行时管理的轻量级线程。要在新的goroutine中运行函数,只需在函数调用之前加上“go”关键字。

package main

import (
    "fmt"
    "time"
)

func main() {
    go say("let's go!", 3)
    go say("ho!", 2)
    go say("hey!", 1)
    time.Sleep(4 * time.Second)
}

func say(text string, secs int) {
    time.Sleep(time.Duration(secs) * time.Second)
    fmt.Println(text)
}

15 并发版本的回声服务器

package main

import (
    "io"
    "log"
    "net"
)

const listenAddr = "localhost:4000"

func main() {
    l, err := net.Listen("tcp", listenAddr)
    if err != nil {
        log.Fatal(err)
    }
    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        go io.Copy(c, c)
    }
}

16 “聊天轮盘”

在本次演讲中,我们将看一个基于流行的“聊天轮盘”网站的简单程序。

简而言之:

  • 用户连接,
  • 另一个用户连接,
  • 每一个用户输入的所有内容都会发送给另一个用户。

17 设计

聊天程序类似于回声程序。使用echo,我们将连接的传入数据复制回到同一连接。

对于聊天,我们必须将传入数据从一个用户的连接复制到另一个用户的连接。

复制数据很容易。就像在现实生活中一样,困难的部分是将一个用户与另一个用户匹配。

18 设计图

19 通道(Channel)

Goroutines通过通道进行通信。通道是一个类型化的管道,可以是同步的(无缓冲的)或异步的(有缓冲的)。

package main

import "fmt"

func main() {
    ch := make(chan int)
    go fibs(ch)
    for i := 0; i < 20; i++ {
        fmt.Println(<-ch)
    }
}

func fibs(ch chan int) {
    i, j := 0, 1
    for {
        ch <- j
        i, j = j, i+j
    }
}

20 选择(select)

一个select语句就像一个开关,但它选择通道的操作(并选择其中一个)。

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(time.Millisecond * 250)
    boom := time.After(time.Second * 1)
    for {
        select {
        case <-ticker.C:
            fmt.Println("tick")
        case <-boom:
            fmt.Println("boom!")
            return
        }
    }
}

21 修改回声程序以创建聊天程序

在Accept循环中,我们替换了对io.Copy的调用:

    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        go io.Copy(c, c)
    }

通过调用新函数,match:

    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        go match(c)
    }

22 匹配器

match函数尝试在通道上同时发送和接收连接。

  • 如果发送成功,则连接已移交给另一个goroutine,因此函数退出并且goroutine关闭。
  • 如果接收成功,则表示已从另一个goroutine接收到连接。然后当前的goroutine已有两个连接,因此它在它们之间启动一个聊天会话。
var partner = make(chan io.ReadWriteCloser)

func match(c io.ReadWriteCloser) {
    fmt.Fprint(c, "Waiting for a partner...")
    select {
    case partner <- c:
        // 现在由另一个goroutine处理
    case p := <-partner:
        chat(p, c)
    }
}

23 交谈

聊天功能向每个连接发送问候,然后将数据从一个连接复制到另一个,反之亦然。

请注意,它启动了另一个goroutine,以便复制操作可能同时发生。

func chat(a, b io.ReadWriteCloser) {
    fmt.Fprintln(a, "Found one! Say hi.")
    fmt.Fprintln(b, "Found one! Say hi.")
    go io.Copy(a, b)
    io.Copy(b, a)
}

24 演示(Demo)

25 错误处理

谈话结束后进行清理很重要。为此,我们将每个io.Copy调用的错误值发送到通道,记录任何非零值错误,并关闭两个连接。

func chat(a, b io.ReadWriteCloser) {
    fmt.Fprintln(a, "Found one! Say hi.")
    fmt.Fprintln(b, "Found one! Say hi.")
    errc := make(chan error, 1)
    go cp(a, b, errc)
    go cp(b, a, errc)
    if err := <-errc; err != nil {
        log.Println(err)
    }
    a.Close()
    b.Close()
}

func cp(w io.Writer, r io.Reader, errc chan<- error) {
    _, err := io.Copy(w, r)
    errc <- err
}

26 演示

27 把它带到网上

“可爱的程序,”你说,“但是谁想通过原始TCP连接来聊天呢?”

好观点。让我们通过将其转变为Web应用程序来对其进行现代化改造。

我们将使用websocket代替TCP套接字。

我们将使用Go的标准net/http包提供用户界面,并且websocket支持由go.net子存储库中的websocket包提供。

28 Hello, web

package main

import (
    "fmt"
    "log"
    "net/http"
)

const listenAddr = "localhost:4000"

func main() {
    http.HandleFunc("/", handler)
    err := http.ListenAndServe(listenAddr, nil)
    if err != nil {
        log.Fatal(err)
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello, web")
}

29 Hello, WebSocket

var sock = new WebSocket("ws://localhost:4000/");
sock.onmessage = function(m) { console.log("Received:", m.data); }
sock.send("Hello!\n")

package main

import (
    "fmt"
    "golang.org/x/net/websocket"
    "net/http"
)

func main() {
    http.Handle("/", websocket.Handler(handler))
    http.ListenAndServe("localhost:4000", nil)
}

func handler(c *websocket.Conn) {
    var s string
    fmt.Fscan(c, &s)
    fmt.Println("Received:", s)
    fmt.Fprint(c, "How do you do?")
}

30 使用http和websocket包

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"

    "golang.org/x/net/websocket"
)

const listenAddr = "localhost:4000"

func main() {
    http.HandleFunc("/", rootHandler)
    http.Handle("/socket", websocket.Handler(socketHandler))
    err := http.ListenAndServe(listenAddr, nil)
    if err != nil {
        log.Fatal(err)
    }
}

31 提供HTML和JavaScript

import "html/template"
func rootHandler(w http.ResponseWriter, r *http.Request) {
    rootTemplate.Execute(w, listenAddr)
}

var rootTemplate = template.Must(template.New("root").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script>
    websocket = new WebSocket("ws://{{.}}/socket");
    websocket.onmessage = onMessage;
    websocket.onclose = onClose;
</html>
`))

32 添加套接字(socket)类型

我们不能只使用websocket.Conn而不是net.Conn,因为websocket.Conn由其处理函数保持打开状态。在这里,我们使用一个通道来保持处理程序运行,直到调用套接字的Close方法。

type socket struct {
    conn *websocket.Conn
    done chan bool
}

func (s socket) Read(b []byte) (int, error)  { return s.conn.Read(b) }
func (s socket) Write(b []byte) (int, error) { return s.conn.Write(b) }

func (s socket) Close() error {
    s.done <- true
    return nil
}

func socketHandler(ws *websocket.Conn) {
    s := socket{conn: ws, done: make(chan bool)}
    go match(s)
    <-s.done
}

33 结构嵌入

Go支持一种具有“结构嵌入”特性的“混合”功能。被嵌入结构的类型(下例中的B)可以直接调用嵌入结构(下例中的A)的方法(下例中的Hello)。

type A struct{}

func (A) Hello() {
    fmt.Println("Hello!")
}

type B struct {
    A
}

// func (b B) Hello() { b.A.Hello() } // (隐式的!)

func main() {
    var b B
    b.Hello()
}

34 嵌入websocket连接

通过将*websocket.Conn嵌入io.ReadWriter,我们可以删除套接字显式的Read和Write方法。

type socket struct {
    io.ReadWriter
    done          chan bool
}

func (s socket) Close() error {
    s.done <- true
    return nil
}

func socketHandler(ws *websocket.Conn) {
    s := socket{ws, make(chan bool)}
    go match(s)
    <-s.done
}

35 演示

36 缓解孤独

如果你连接了,但那里没有人怎么办?

如果我们能合成一个聊天伙伴不是很好吗?

我们开始做吧。

37 使用马尔可夫链生成文本

Source
"I am not a number! I am a free man!"

Prefix           Suffix 
"" ""            "I"
"" "I"           "am"
"I" "am"         "a"
"I" "am"         "not"
"a" "free"       "man!"
"am" "a"         "free"
"am" "not"       "a"
"a" "number!"    "I"
"number!" "I"    "am"
"not" "a"        "number!"

Generated sentences beginning with the prefix "I am"
"I am a free man!"
"I am not a number! I am a free man!"
"I am not a number! I am not a number! I am a free man!"
"I am not a number! I am not a number! I am not a number! I am a free man!"

38 使用马尔可夫链生成文本

幸运的是,Go doc包含一个马尔可夫链实现:

golang.org/doc/codewalk/markov

我们将使用经过修改以确保并发使用的版本。

// Chain包含前缀到后缀列表的映射map ("chain")。
// 前缀是由空格连接的prefixLen长度的字符串串。
// 后缀是一个单词。一个前缀可以有多个后缀。
type Chain struct {

// Write将字节解析为存储在Chain中的前缀和后缀。
func (c *Chain) Write(b []byte) (int, error) {

// Generate返回从Chain生成的最多n个单词的字符串。
func (c *Chain) Generate(n int) string {

39 给链(chan)喂食

我们将使用进入系统的所有文本来构建马尔可夫链。

为此,我们将套接字的ReadWriter拆分为Reader和Writer,并将所有传入数据提供给Chain实例。

type socket struct {
    io.Reader
    io.Writer
    done chan bool
}

var chain = NewChain(2) // 2个单词前缀

func socketHandler(ws *websocket.Conn) {
    r, w := io.Pipe()
    go func() {
        _, err := io.Copy(io.MultiWriter(w, chain), ws)
        w.CloseWithError(err)
    }()
    s := socket{r, ws, make(chan bool)}
    go match(s)
    <-s.done
}

40 马尔可夫机器人

// Bot返回一个io.ReadWriteCloser,它使用生成的句子响应给每个传入的写入。
func Bot() io.ReadWriteCloser {
    r, out := io.Pipe() // 用于传出数据
    return bot{r, out}
}

type bot struct {
    io.ReadCloser
    out io.Writer
}

func (b bot) Write(buf []byte) (int, error) {
    go b.speak()
    return len(buf), nil
}

func (b bot) speak() {
    time.Sleep(time.Second)
    msg := chain.Generate(10) // 最多10个单词
    b.out.Write([]byte(msg))
}

41 集成马尔可夫机器人

如果真正的伙伴不加入,那么加入一个机器人。

为此,我们在5秒后触发的select中添加一个case,开始用户的套接字和机器人之间的聊天。

func match(c io.ReadWriteCloser) {
    fmt.Fprint(c, "Waiting for a partner...")
    select {
    case partner <- c:
        // 现在由另一个goroutine处理
    case p := <-partner:
        chat(p, c)
    case <-time.After(5 * time.Second):
        chat(Bot(), c)
    }
}

42 演示

43 还有一件事情

44 同时使用TCP和HTTP

func main() {
    go netListen()
    http.HandleFunc("/", rootHandler)
    http.Handle("/socket", websocket.Handler(socketHandler))
    err := http.ListenAndServe(listenAddr, nil)
    if err != nil {
        log.Fatal(err)
    }
}

func netListen() {
    l, err := net.Listen("tcp", "localhost:4001")
    if err != nil {
        log.Fatal(err)
    }
    for {
        c, err := l.Accept()
        if err != nil {
            log.Fatal(err)
        }
        go match(c)
    }
}

45 演示

46 讨论

47 进一步阅读

关于Go:

golang.org

本次演讲的幻灯片:

go.dev/talks/2012/chat.slide

Rob Pike的“Go并发模式”:

golang.org/s/concurrency-patterns

48 致谢

Andrew Gerrand

Google Sydney

http://andrewgerrand.com/

@enneff

http://golang.org/