Nginx HTTP/2 Server Push

Nginx 在最新的 1.13.9 版本中,增加了对 HTTP/2 Server Push 的支持,以下就简单介绍下如何使用。

以下内容主要来自 Introducing HTTP/2 Server Push with NGINX 1.13.9,进行了简单的整理。

推送指定资源

首先,在 Nginx 中进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
#开启HTTP/2
listen 443 ssl http2;
ssl_certificate ssl/certificate.pem;
ssl_certificate_key ssl/key.pem;
root /var/www/html;
# 当请求 demo.html 时,推送 /style.css, /image1.jpg, /image2.jpg
location = /demo.html {
http2_push /style.css;
http2_push /image1.jpg;
http2_push /image2.jpg;
}
}

可以通过 Chrome Developer Tools 查看效果。在 Network 中,可以看到 demo.html 的请求和推送到客户端的内容。

如下图所示,可以看到 style.css,image1.jpg,image2.jpg。它们都是 demo.html 请求的一部分。

自动推送

以上是在页面加载时推送指定资源的例子,但是很多情况下,并不能指定要推送哪些资源,因此,Nginx 还支持通过 http2_push_preload 指令,自动分析响应头中的 Link header,来自动推送这些资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server {
#开启HTTP/2
listen 443 ssl http2;
ssl_certificate ssl/certificate.pem;
ssl_certificate_key ssl/key.pem;
root /var/www/html;
location = /myapp {
proxy_pass http://upstream;
http2_push_preload on;
}
}

通过如上的配置,当 upstream 返回的响应头中包含 Link header 时,

1
Link: </style.css>; as=style; rel=preload

Nginx 即会开启一个推送, 内容则是 /style.css ,Link header 的路径必须是绝对路径。

如果想要推送多个资源,可以添加多个 Link header , 或更直接一些,把所有资源添加到一个 Link header 中。

1
Link: </style.css>; as=style; rel=preload, </favicon.ico>; as=image; rel=preload

如果不想让 Nginx 推送某个资源,为该 header 添加一个 nopush 参数即可。

1
Link: </nginx.png>; as=image; rel=preload; nopush

有选择的推送

由于 HTTP/2 规范并没有解决是否要推送哪些资源的问题,但是比较合理的方式是知道需要推送哪些资源,并且客户端没有缓存过这些资源,才进行推送。

所以 Nginx 支持了添加条件,只在符合条件时才进行推送

当客户端请求时包含了 cookie ,Nginx 将只会推送一次资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server {
listen 443 ssl http2 default_server;
ssl_certificate ssl/certificate.pem;
ssl_certificate_key ssl/key.pem;
root /var/www/html;
http2_push_preload on;
location = /demo.html {
add_header Set-Cookie "session=1";
add_header Link $resources;
}
}
map $http_cookie $resources {
"~*session=1" "";
default "</style.css>; as=style; rel=preload, </image1.jpg>; as=image; rel=preload,
</image2.jpg>; as=style; rel=preload";
}

相关链接:

在Go中使用 HTTP/2 Server Push

参考资料:

Server Push (HTTP/2)

Go slice,struct排序

Go中有时会需要对slice,或多个struct进行排序,其实很简单。

slice

对于 slice 的排序,可以直接使用 sort 包提供的方法,

int

1
2
3
s := []int{3,2,4,1}
sort.Ints(s)
fmt.Println(s) // [1,2,3,4]

string

1
2
3
s := []string{"Go", "Bravo", "Gopher", "Alpha", "Grin", "Delta"}
sort.Strings(s)
fmt.Println(s) // [Alpha Bravo Delta Go Gopher Grin]

float

1
2
3
s := []float64{5.2, -1.3, 0.7, -3.8, 2.6}
sort.Float64s(s)
fmt.Println(s) // [-3.8,-1.3,0.7,2.6,5.2]

struct

以上都是 sort 默认提供的方法,但是对于 struct ,就需要自己实现 sort.Interface。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Person struct {
Name string
Age int
}
type byAge []Person
func (a ByAge) Len() int { return len(a) }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func main() {
family := []Person{
{"Alice", 23},
{"Eve", 2},
{"Bob", 25},
}
sort.Sort(ByAge(family))
fmt.Println(family) // [{Eve 2} {Alice 23} {Bob 25}]
}

Go struct 转 map 使用自定义标签

今天工作遇到一个问题,之前将 struct 转 map 的时候,没有注意 field 大小写的问题,具体的说,是没有注意 field name 与实际需要的 name 的区别,其实就是需要自定义转为 map 之后的name,今天发现问题后,看了下引用包的源码,发现是可以自定义标签的,就跟 struct 转 JSON 一样。

代码也很简单,加上 `structs:"name"` 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import (
"fmt"
"github.com/fatih/structs"
)
type Server struct {
Name string `structs:"server_name"`
ID int `structs:"server_id"`
}
func main() {
server := &Server{
Name: "gopher",
ID: 123456,
}
fmt.Printf("struct : %v\n", server) //struct : &{gopher 123456}
serverMap := structs.Map(server)
fmt.Printf("map : %v\n", serverMap) //map : map[server_name:gopher server_id:123456]

这样就可以拿到自定义key的map了。

Go 检测文件内容类型

有时候需要检测文件的内容类型或 MIME 类型,为此,需要打开文件并读取前512个字节(因为DetectContentType()函数值使用前512个字节),所以不需要读取更多内容。这个函数会返回一个 MIME 类型,如 application/json 或 image/jpeg。

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
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
fileArr := []string{"file/1.pdf", "file/2.jpg", "file/3.docx", "file/4.xml", "file/5.azw3", "file/6.zip", "file/7.torrent"}
for _, fileName := range fileArr {
f, err := os.Open(fileName)
if err != nil {
panic(err)
}
defer f.Close()
contentType, err := getFileContentType(f)
if err != nil {
panic(err)
}
fmt.Print(fmt.Sprintf("File Name : %v, Content Type : %s\n", fileName, contentType))
}
}
func getFileContentType(out *os.File) (string, error) {
buffer := make([]byte, 512)
_, err := out.Read(buffer)
if err != nil {
return "", err
}
contentType := http.DetectContentType(buffer)
return contentType, nil
}

Docker多阶段构建

Docker在17.05引入了多阶段构建的功能,就是将之前需要多次运行build的Dockerfile,现在可以写到一个里面,只build一次,就可以达到同样的效果。

另外,实际应用中,还可以通过这样,非常简单的将最后可执行文件放入极小的镜像中使用。

比如之前说过的,做一个Beego的Docker镜像,如果使用官方的镜像运行,占用空间稍微有点大了,虽然比起Ubuntu,CentOS动辄5-600M还好一些,但是再跟只有10几M,甚至几M的相比,还是太大了。

下面通过一个Dockerfile来了解一下:

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
#指定构建镜像
FROM golang:1.9.2 as builder
#指定工作目录
WORKDIR /go/src/app
#将当前项目文件copy到够姜镜像的工作目录中
COPY . .
#在构建镜像中执行go build,这里指定了构建的目标平台,具体的构建命令针对具体情况修改即可
#也可简单的 go build 即可
#另外需要注意依赖包的问题
RUN CGO_ENABLED=0 GOOS=linux go build -x -v -ldflags '-w -s' -a -o app .
#使用alpine作为运行的镜像
FROM alpine:latest
#指定工作目录
WORKDIR /go/src/app
#如果程序中涉及到需要连接DB,并且需要指定时区,需要copy时区文件,为了省事,直接从构建镜像中复制了,可以正常使用
COPY --from=builder /usr/local/go/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip
#将构建镜像中build完成的可执行文件copy到工作目录
COPY --from=builder /go/src/app/app ./app
EXPOSE 8080
CMD ["./app"]

以上就是使用多阶段构建的Dockerfile了,非常明了,只需要docker build就可以了。

然而目前公司的测试环境还没升级到17.05,并不能使用此功能。

Go语言使用redigo操作GEO(3) - 获取范围集合

上一篇记录了使用redigo操作Redis的获取两点距离的功能。

今天继续来写一下,获取一个坐标点指定距离范围内地理位置的集合。

完成此功能,需要使用Redis的 GEORADIUS 命令。

语法:

1
GEORADIUS key longitude latitude radius [m|km|ft|mi] [WITHCOORD] [WITHDIST] [ASC|DESC] [WITHHASH] [COUNT count]

其中,longitude latitude 表示地理位置的坐标,radius表示范围的距离,单位可以是m,km,ft,mi,依次为米,千米,英里,英尺。

后边的可选参数中:

  • WITHCOORD:传入WITHCOORD参数,返回结果会带上匹配位置的经纬度。
  • WITHDIST:传入WITHDIST参数,返回结果会带上匹配位置与给定地理位置的距离,距离的单位与 GEORADIUS 命令传入的单位一致。
  • ASC|DESC:默认结果是未排序的,ASC表示从近到远排序,DESC表示从远到近排序。
  • WITHHASH:传入WITHHASH参数,返回结果会带上匹配位置的hash值。hash为52位有符号整数。
  • COUNT count:传入COUNT参数,返回指定数量的结果。

参照文档,可以发现,默认情况下 GEORADIUS 会返回全部匹配的元素,而传入 COUNT 参数会截取指定的部分元素返回,但是由于此命令还需要对返回结果进行排序,所以如果元素较多的情况下,即使使用了 COUNT 参数,查找速度也会很慢。所以此参数仅适用于减小带宽占用,一次性返回少数数据,多次查询。

GEORADIUS 的返回值是一个数组:

  • 如果传入了WITHCOORD,WITHDIST,WITHHASH参数,会返回二维的数组,其中每个子数组表示一个元素
  • 如果没有传入上述参数,会返回一个一维的数组,值为元素名,如[“tianjin”,”baoding”]

如果返回的是二维数组,子数组的第一个元素是对应位置的名字,其他的会根据传入的参数当做数组的元素返回,其中:

  • 距离依然是一个双精度浮点数,单位与传入的单位参数一致。
  • GEOHASH 是一个整数。
  • 坐标分别为经度,纬度,其中经度在前。

如:

1
GEORADIUS citylist 116.280316 39.9329 200 km WITHCOORD

会返回

1
2
3
4
5
6
7
8
9
10
11
12
1) 1) "beijing"
2) 1) "116.40528291463851929"
2) "39.9049884229125027"
2) 1) "baoding"
2) 1) "115.33530682325363159"
2) "38.87121760640306434"
3) 1) "tangshan"
2) 1) "116.94219142198562622"
2) "39.05078232277295314"
4) 1) "tianjin"
2) 1) "117.0153459906578064"
2) "39.12522961794389431"

那么,使用redigo如何实现呢?

有WITHCOORD,WITHDIST,WITHHASH参数

暂时还没想到怎么解决这个比较好。

无WITHCOORD,WITHDIST,WITHHASH参数

1
2
3
4
5
6
7
func radius(key string, lng, lat float64, radius int, unit string) []string {
rc := RedisClient.Get()
defer rc.Close()
pos, _ := redis.Strings(rc.Do("GEORADIUS", key, lng, lat, radius, unit))
return pos
}

该方法则会返回符合条件的元素的名称,如:

1
[beijing baoding tangshan tianjin]

Go语言使用redigo操作GEO(2) - 获取两点距离

上一篇简单记录了在Go中使用redigo这个包操作Redis,简单的使用了添加和查询。

今天来试一下计算距离。之前也写到过在MySQL中计算坐标的距离,及排序

在上一篇中疏忽了一个问题,使用的几个坐标点是用高德地图获取到的,但是Redis中GEO是使用了 WGS84 坐标系的。实际使用中需要注意一下,这里有一篇文章详细介绍了不同坐标系的区别。

添加测试数据

先添加几条测试用的数据。

1
2
3
push("citylist", "tianjin", 39.1252291, 117.0153461)
push("citylist", "tangshan", 39.0507819, 116.9421939)
push("citylist", "baoding", 38.8712164, 115.3353061)

添加了三个城市。

查询距离

使用GEODIST方法查询两点的距离。手册见此

语法: GEODIST key member1 member2 [unit]

第四个参数可选,可选值为:

  • m 表示单位为米。
  • km 表示单位为千米。
  • mi 表示单位为英里。
  • ft 表示单位为英尺。

默认是米。

简单写一个方法,传入key和要查询的两点,及距离单位。

1
2
3
4
5
6
7
8
func dist(key, m1, m2, unit string) float64 {
rc := RedisClient.Get()
defer rc.Close()
rs1, _ := redis.Float64(rc.Do("GEODIST", key, m1, m2, unit))
return rs
}

GEODIST返回值是双精度浮点数,这里直接使用redigo包的Float64方法转换返回了。

使用

1
2
3
4
5
6
7
rs1 := dist("citylist", "beijing", "tianjin", "km")
rs2 := dist("citylist", "beijing", "tangshan", "km")
rs3 := dist("citylist", "beijing", "baoding", "km")
fmt.Println("北京-天津", rs1)
fmt.Println("北京-唐山", rs2)
fmt.Println("北京-保定", rs3)

Go语言使用redigo操作GEO(1) - 添加和查询

周五的时候群里有人遇到在Go中使用Redis GEO的问题,顺手搜了下解决办法。发现还挺简单的,周末无事,写下来记一下。

GEO是在Redis 3.2加入的功能,手册见此

本示例中只演示GEOADD和GEOPOS功能。

redigo这个客户端还挺好用的,但是个人觉得略有不足的是文档不全(指的是中文的文档),但是好在可以看源码解决一些不太清楚的问题。

0.连接Redis

首先习惯性的创建了连接池,嗯,连接池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func init() {
RedisClient = &redis.Pool{
MaxIdle: MaxIdle,
MaxActive: MaxActive,
IdleTimeout: 60 * time.Second,
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", RedisHost)
if err != nil {
return nil, err
}
/*if _, err := c.Do("AUTH", RedisPwd); err != nil {
c.Close()
return nil, err
}*/
c.Do("SELECT", RedisDb)
return c, nil
},
}
}

1.添加值

为了简单起见,创建了一个方法用于向传入的key中写入name的坐标点。

直接返回错误。

1
2
3
4
5
6
7
func push(key, name string, lat, lng float64) error {
rc := RedisClient.Get()
defer rc.Close()
_, err := rc.Do("GEOADD", key, lng, lat, name)
return err
}

2.取回值

同样,创建一个方法,在Redis中取回key中name的坐标点的值。

但是注意,这里在执行完 GEOPOS 后,调用 redigo 包中的 Positions 方法把返回结果转成 float64 的数组。

然后返回这个数组和错误

1
2
3
4
5
6
7
8
func get(key, name string) ([]*[2]float64, error) {
rc := RedisClient.Get()
defer rc.Close()
res, err := redis.Positions(rc.Do("GEOPOS", key, name))
return res, err
}

3.main方法中的简单代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
key := "citylist"
name := "beijing"
lat := 39.9329
lng := 116.280316
err := push(key, name, lat, lng)
if err != nil {
panic(err)
}
fmt.Println("坐标写入完成")
fmt.Println("获取刚刚写入的值")
res, err := get(key, name)
if err != nil {
panic(err)
}
fmt.Println(res[0][0], res[0][1])
}

就这么简单。

import部分和定义的几个全局变量就不用写了,

LeetCode 561.Array Partition I(数组分区 1) - Go实现

题目地址:561. Array Partition I

题目描述:

Given an array of 2n integers, your task is to group these integers into n pairs of integer, say (a1, b1), (a2, b2), …, (an, bn) which makes sum of min(ai, bi) for all i from 1 to n as large as possible.

Example 1:
1
2
3
4
5
Input: [1,4,3,2]
Output: 4
Explanation: n is 2, and the maximum sum of pairs is 4 = min(1, 2) + min(3, 4).
Note:

​ n is a positive integer, which is in the range of [1, 10000].
​ All the integers in the array will be in the range of [-10000, 10000].

题目大意:

给定一个长度为2n的数组,要把它分成n个分组,即每组有两个数,返回每组中最小值的总和,使和最大。

理解了大意就知道思路了,又看了下论坛里的算法分析

解决方案基本就是先按从小到大排序,这样相邻的数字是最接近的,然后再分成两两一组,取每组中的第一个数相加即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main
import (
"fmt"
"sort"
)
func main() {
nums := []int{4, 5, 6, 1}
n := arrayPairSum(nums)
fmt.Println(n)
}
func arrayPairSum(nums []int) int {
sort.Ints(nums)
sum := 0
length := len(nums)
for i := 0; i < length; i += 2 {
sum += nums[i]
}
return sum
}

在线查看结果:The Go Playground

其实这里主要用到了Go的sort包给int数组排序。排序后遍历数组,每次递增2就可以了。

【译】使用Go和Angular通过WebSocket构建实时聊天应用

写在前面

本文原文,详细讲解了如何使用Go和Angular通过WebSocket构建实时聊天应用。

正文

我最近听到很多关于WebSocket的东西,以及WebSocket如何在应用程序和服务器之间实现实时通信。WebSocket作为RESTful API的替代和补充,已经存在了很长时间。使用WebSocket可以做例如实时聊天,与IoT通信,游戏,和其他很多需要在客户端和服务器之间进行即时消息传递的东西。

最近一段时间,我使用了一个叫Socket.io的库,用来在Node.js中使用websockets,但是当我真正使用Go以后,我打算研究一下如何在Go中使用WebSocket。

通过本文,我们将学习如何创建一个聊天应用,其中客户端是一个 Angular 2 应用,服务端使用Go。

要求

在这个应用中有很多操作,所以有一些必要的前提条件,如下所示:

处理所有消息和客户端的聊天服务器使用Go编写。客户端前端使用 Angular 2编写,has a dependency of the Node Package Manager (NPM) which ships with Node.js.

创建Go聊天服务器

我们打算先开发整个应用的服务器端部分,它需要依赖几个第三方的包。

在命令行执行以下命令,下载第三方包:

1
2
3
//Install Go Dependencies
go get github.com/gorilla/websocket
go get github.com/satori/go.uuid

websocket包的作者同时也是 Mux 这个路由包 的作者,我们还需要一个UUID包来分配每一个客户端的唯一ID。

在 $GOPATH 目录创建一个新的项目,我自己的项目目录是 $GOPATH/src/github.com/nraboy/realtime-chat/main.go。

在进行下一步之前,需要注意的是,我从 Dinosaurs CodeGorilla websocket chat example 获取了一部分Go 代码,为了避免剽窃的嫌疑,我使用了很多原始代码中的一部分,但我也为这个项目加入了很多自己的独特的东西。

这次我们要做的聊天应用有3个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// $GOPATH/src/github.com/nraboy/realtime-chat/main.go
type ClientManager struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
type Client struct {
id string
socket *websocket.Conn
send chan []byte
}
type Message struct {
Sender string `json:"sender,omitempty"`
Recipient string `json:"recipient,omitempty"`
Content string `json:"content,omitempty"`
}

ClientManager用于管理所有已连接的客户端,尝试连接的客户端,已经断开连接等待删除的客户端,和所有已连接客户端收发的消息。

每个客户端有一个唯一的ID,一个socket连接,和等待发送的消息。

为了增加传递的数据的复杂性,消息将使用 JSON 格式。而不是传递一串不容易被理解,阅读的数据。使用JSON格式,我们可以使用元数据和其他有用的东西。每一条消息将包含发送消息的客户端,接收消息的客户端,和消息的实际内容。

首先定义一个全局的ClientManager。

1
2
3
4
5
6
7
//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
var manager = ClientManager{
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}

服务器端将使用3个goroutine,一个用于管理客户端,一个用于读取websocket数据,另一个用于往websocket里写数据。这里指的是读取和写入的goroutine将为每个连接的客户端创建一个新的实例。所有的goroutine将循环运行直至不再需要。

编写如下代码,来开始服务:

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
//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (manager *ClientManager) start() {
for {
select {
case conn := <-manager.register:
manager.clients[conn] = true
jsonMessage, _ := json.Marshal(&Message{Content: "/A new socket has connected."})
manager.send(jsonMessage, conn)
case conn := <-manager.unregister:
if _, ok := manager.clients[conn]; ok {
close(conn.send)
delete(manager.clients, conn)
jsonMessage, _ := json.Marshal(&Message{Content: "/A socket has disconnected."})
manager.send(jsonMessage, conn)
}
case message := <-manager.broadcast:
for conn := range manager.clients {
select {
case conn.send <- message:
default:
close(conn.send)
delete(manager.clients, conn)
}
}
}
}
}

每当 manager.register 接收到数据,这个正在建立连接的客户端将会被添加到 manager (前文创建的 ClientManager 实例)的 clients 中。然后,将向所有其他客户端发送一条JSON消息。

同时,如果客户端断开连接,manager.unregister channel将会收到消息,断开连接的客户端的 channel 中的数据将被关闭,客户端也会从manager中删除。然后发送消息给其他的客户端告知某个客户端已断开连接。

如果 manager.broadcast channel 中存在数据,则表示正在尝试发送和接收消息。我们打算遍历每个已连接的客户端,将消息发送给它们。如果由于某些原因,channel 被阻塞或消息无法发送,我们会认为这个客户端已断开连接,然后将其删除。

为了使代码简洁,创建一个 manager.send 方法遍历每个客户端。

1
2
3
4
5
6
7
8
//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (manager *ClientManager) send(message []byte, ignore *Client) {
for conn := range manager.clients {
if conn != ignore {
conn.send <- message
}
}
}

至于conn.send如何发送数据,会在后面探讨。

现在我们可以探索 goroutine 如何读取客户端发送的 websocket 数据。这个 goroutine 的关键是读取 socket 数据,并将数据添加到 manager.boradcast 做进一步处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (c *Client) read() {
defer func() {
manager.unregister <- c
c.socket.Close()
}()
for {
_, message, err := c.socket.ReadMessage()
if err != nil {
manager.unregister <- c
c.socket.Close()
break
}
jsonMessage, _ := json.Marshal(&Message{Sender: c.id, Content: string(message)})
manager.broadcast <- jsonMessage
}
}

如果读取 websocket 数据出错,可能意味着客户端已经断开连接。如果是这样,我们需要从服务器中注销这个客户端。

还记得前边的 conn.send 吗,它用来在第三个 goroutine 中写数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//$GOPATH/src/github.com/nraboy/realtime-chat/main.go
func (c *Client) write() {
defer func() {
c.socket.Close()
}()
for {
select {
case message, ok := <-c.send:
if !ok {
c.socket.WriteMessage(websocket.CloseMessage, []byte{})
return
}
c.socket.WriteMessage(websocket.TextMessage, message)
}
}
}

如果 c.send channel有数据,我们将尝试发送这些数据。如果由于某些原因,channel 运行不正常,我们将向客户端发送断开连接的消息。

那么,如何启动这些 goroutine 呢,当我们启动服务器时,服务器 goroutine 将会启动,当有客户端连接时,其他 goroutine 将会启动。

main方法中的代码:

1
2
3
4
5
6
7
//$GOPATH/src/github.com/nraboy/realtime-chat/main.goGo
func main() {
fmt.Println("Starting application...")
go manager.start()
http.HandleFunc("/ws", wsPage)
http.ListenAndServe(":12345", nil)
}

我们在12345端口启动服务器,通过 websocket 连接访问。名为 wsPage 的方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//$GOPATH/src/github.com/nraboy/realtime-chat/main.goGo
func wsPage(res http.ResponseWriter, req *http.Request) {
conn, error := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}).Upgrade(res, req, nil)
if error != nil {
http.NotFound(res, req)
return
}
client := &Client{id: uuid.NewV4().String(), socket: conn, send: make(chan []byte)}
manager.register <- client
go client.read()
go client.write()
}

通过使用 websocket 包将HTTP请求升级到websocket请求。通过添加 CheckOrigin ,我们可以接受来自外部域的请求,从而消除跨域资源共享(CORS)的错误。

创建连接后,将创建一个客户端,并分配唯一的ID。如前所述,该客户端已经注册到服务器。客户端注册后,读写 goroutine 将被触发。

此时,我们可以通过如下命令启动应用。

1
2
//Run Go Application
go run *.go

你不能在直接 web 浏览器中测试,但是可以建立一个 websocket 连接到 ws://localhost:12345/ws。

创建Angular2 聊天客户端

现在我们需要创建一个客户端的应用,客户端可以发送和接收消息。假设您已经安装了Angular 2 CLI,请执行以下操作:

1
2
//Create New Angular 2 Project
ng new SocketExample

执行完将会生成一个单页应用,而我们想要完成的内容,是下方的动图演示的这样。

补充:此处需cd SocketExmapl && npm install。

JavaScript的 websocket 在Angular 2提供的一个类中。使用 Angular 2 CLI,通过执行如下操作创建provider。

1
2
//Create Angular 2 Provider
ng g service socket

上述命令会在您的项目中创建 src/app/socket.service.ts src/app/socket.service.spec.ts 。spec文件用于单元测试,不在本文讨论范围内。打开 src/app/socket.service.ts 文件,编写以下 TypeScript 代码:

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
//src/app/socket.service.ts
import { Injectable, EventEmitter } from '@angular/core';
@Injectable()
export class SocketService {
private socket: WebSocket;
private listener: EventEmitter<any> = new EventEmitter();
public constructor() {
this.socket = new WebSocket("ws://localhost:12345/ws");
this.socket.onopen = event => {
this.listener.emit({"type": "open", "data": event});
}
this.socket.onclose = event => {
this.listener.emit({"type": "close", "data": event});
}
this.socket.onmessage = event => {
this.listener.emit({"type": "message", "data": JSON.parse(event.data)});
}
}
public send(data: string) {
this.socket.send(data);
}
public close() {
this.socket.close();
}
public getEventListener() {
return this.listener;
}
}

该提供者是可以注射的,并在触发某些事件事发送数据。在构造方法中,建立了与Go应用的WebSocket 连接,并创建了3个事件监听器。分别对应每个socket创建和销毁时,及接收到消息时。

send方法允许我们向Go应用发送消息,close方法用于通知Go应用我们将断开连接。

提供者程序已创建,但是还不能在我们的的应用程序的任何文件中使用。因此,我们需要将其添加到 src/app/app.module.ts 文件的 @NgModule 块中。打开文件并输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';
import { SocketService } from "./socket.service";
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule
],
providers: [SocketService],
bootstrap: [AppComponent]
})
export class AppModule { }

需要注意的是,此时我们已经将provider导入并且添加到 @NgModule 块的 providers数组中了。

现在我们可以专注处理页面的逻辑了。打开 src/app/app.component.ts 文件,并输入以下代码:

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
//src/app/app.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SocketService } from "./socket.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
public messages: Array<any>;
public chatBox: string;
public constructor(private socket: SocketService) {
this.messages = [];
this.chatBox = "";
}
public ngOnInit() {
this.socket.getEventListener().subscribe(event => {
if(event.type == "message") {
let data = event.data.content;
if(event.data.sender) {
data = event.data.sender + ": " + data;
}
this.messages.push(data);
}
if(event.type == "close") {
this.messages.push("/The socket connection has been closed");
}
if(event.type == "open") {
this.messages.push("/The socket connection has been established");
}
});
}
public ngOnDestroy() {
this.socket.close();
}
public send() {
if(this.chatBox) {
this.socket.send(this.chatBox);
this.chatBox = "";
}
}
public isSystemMessage(message: string) {
return message.startsWith("/") ? "<strong>" + message.substring(1) + "</strong>" : message;
}
}

在上述 AppComponent类的构造方法中,我们注册服务提供者并初始化需要绑定到UI的变量。在构造函数中加载或订阅不太好,我们使用ngOninit方法来代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//src/app/app.component.ts
public ngOnInit() {
this.socket.getEventListener().subscribe(event => {
if(event.type == "message") {
let data = event.data.content;
if(event.data.sender) {
data = event.data.sender + ": " + data;
}
this.messages.push(data);
}
if(event.type == "close") {
this.messages.push("/The socket connection has been closed");
}
if(event.type == "open") {
this.messages.push("/The socket connection has been established");
}
});
}

在上述方法中,我们订阅了在provider中创建的事件监听器。在这里我们需要检查发生了什么事件。如果是一条消息,需要检查是否存在发件人,然后将其添加到消息中。

你可能注意到了,一些消息是以斜线开始的。用来表示系统消息,稍后会将其加粗。

当客户端断开时,关闭事件将会发送到服务器,如果消息已经发送,它也会被发送到服务器。

在查看HTML之前,先添加一些CSS,使其看起来更像一个聊天应用。打开 src/style.css,输入以下内容:

1
2
3
4
5
6
7
8
9
10
/*src/styles.css*/
/* You can add global styles to this file, and also import other style files */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font: 13px Helvetica, Arial; }
form { background: #000; padding: 3px; position: fixed; bottom: 0; width: 100%; }
form input { border: 0; padding: 10px; width: 90%; margin-right: .5%; }
form button { width: 9%; background: rgb(130, 224, 255); border: none; padding: 10px; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }

现在,需要处理下HTML了。打开 src/app/app.component.html 文件,并输入以下内容:

1
2
3
4
5
6
7
8
9
10
<!--src/app/app.component.html-->
<ul id="messages">
<li *ngFor="let message of messages">
<span [innerHTML]="isSystemMessage(message)"></span>
</li>
</ul>
<form action="">
<input [(ngModel)]="chatBox" [ngModelOptions]="{standalone: true}" autocomplete="off" />
<button (click)="send()">Send</button>
</form>

这里我们只是简单的将消息数组遍历到屏幕上 。以斜线开头的消息将会被加粗。提交按钮绑定到了send方法中,当按下时,会提交输入框中的内容到Go应用。

结语

刚刚演示了如何使用 Go 和 Angular 2 创建一个 WebSocket 实时聊天应用。虽然没有在这个示例中存储聊天记录,但是这套逻辑可以应用于更复杂的项目,比如游戏,IOT,和其他很多场景。

关于原作者

Nic Raboy是现代网络和移动开发技术的倡导者。 他在Java,JavaScript,Golang以及各种框架(如Angular,NativeScript和Apache Cordova)方面拥有丰富的经验。 Nic写作的内容主要是他在使Web和移动开发更容易理解相关方面的经验。