Nginx HTTP/2 Server Push

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

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

推送指定资源

首先,在 Nginx 中进行配置:

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,来自动推送这些资源。

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 时,

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

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

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

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

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

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

有选择的推送

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

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

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

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

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

string

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

float

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。

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"` 即可。

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。

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来了解一下:

#指定构建镜像
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 命令。

语法:

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 是一个整数。
  • 坐标分别为经度,纬度,其中经度在前。

如:

 GEORADIUS citylist 116.280316 39.9329 200 km WITHCOORD

会返回

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参数

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
}

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

[beijing baoding tangshan tianjin]

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

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

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

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

添加测试数据

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

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和要查询的两点,及距离单位。

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方法转换返回了。

使用

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

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

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的坐标点。

直接返回错误。

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 的数组。

然后返回这个数组和错误

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方法中的简单代码

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:
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个分组,即每组有两个数,返回每组中最小值的总和,使和最大。

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

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

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聊天服务器

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

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

//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个结构体:

// $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。

//$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将循环运行直至不再需要。

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

//$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 方法遍历每个客户端。

//$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 做进一步处理。

//$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 中写数据。

//$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方法中的代码:

//$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 的方法如下所示:

//$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 将被触发。

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

//Run Go Application
go run *.go

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

创建Angular2 聊天客户端

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

//Create New Angular 2 Project
ng new SocketExample

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

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

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

//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 代码:

//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 块中。打开文件并输入:

//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 文件,并输入以下代码:

//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方法来代替。

//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,输入以下内容:

/*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 文件,并输入以下内容:

<!--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和移动开发更容易理解相关方面的经验。