├── .travis.yml ├── .gitignore ├── go.mod ├── .env.example ├── README.md ├── metrics.go ├── channel.go ├── doc ├── USAGE.md └── Private-example.md ├── main.go ├── redis.go ├── auth.go ├── go.sum └── websocket.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.12.x 4 | script: go build -o client -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .idea/ 4 | client 5 | tls/ 6 | .vscode/ 7 | client-linux 8 | client-windows.exe 9 | go-laravel-broadcast -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Qsnh/go-laravel-broadcast 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/gomodule/redigo v2.0.0+incompatible 7 | github.com/gorilla/websocket v1.4.0 8 | github.com/joho/godotenv v1.3.0 9 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a 10 | github.com/sirupsen/logrus v1.4.1 11 | ) 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Laravel认证 2 | AUTH_HOST=http://localhost 3 | AUTH_PATH=/broadcasting/auth 4 | 5 | # HTTPS 6 | TLS_ENABLED=false 7 | TLS_CERT_FILE= 8 | TLS_KEY_FILE= 9 | 10 | # WebSocket服务 11 | WEBSOCKET_HOST=0.0.0.0 12 | WEBSOCKET_PORT=8890 13 | WEBSOCKET_PATH=/ws 14 | WEBSOCKET_CHECK_ORIGIN=* 15 | 16 | # Redis配置 17 | REDIS_HOST=127.0.0.1 18 | REDIS_PORT=6379 19 | REDIS_PASSWORD= 20 | SUBSCRIBE_CHANNELS= -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Broadcast 2 | 3 | [![Build Status](https://travis-ci.org/Qsnh/go-laravel-broadcast.png)](https://travis-ci.org/Qsnh/go-laravel-broadcast) 4 | [![Release](https://img.shields.io/github/release/Qsnh/go-laravel-broadcast.svg?label=Release)](https://github.com/Qsnh/go-laravel-broadcast/releases) 5 | 6 | [Usage](doc/USAGE.md) 7 | 8 | 基于 Go 实现的 [laravel-echo-server](https://github.com/tlaverdure/laravel-echo-server) 。速度更快,占用资源更少,更易于多平台运行。 9 | 10 | ### Features 11 | 12 | + [x] Websocket服务 13 | + [x] Redis订阅 14 | + [x] 定时输出数据统计 15 | + [x] 心跳检测 16 | + [x] HTTPS 17 | + [x] 数据接口 18 | 19 | ### Author 20 | 21 | [小滕博客](https://58hualong.cn) | [小滕全栈教学](https://58hualong.com) 22 | 23 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | gometrics "github.com/rcrowley/go-metrics" 6 | log "github.com/sirupsen/logrus" 7 | "time" 8 | ) 9 | 10 | type LocalMetrics struct { 11 | MessageCount gometrics.Counter 12 | ClientCount gometrics.Counter 13 | } 14 | 15 | var metrics = &LocalMetrics{ 16 | MessageCount: gometrics.NewCounter(), 17 | ClientCount: gometrics.NewCounter(), 18 | } 19 | 20 | func (m *LocalMetrics) Report() { 21 | ticker := time.NewTicker(10 * time.Second) 22 | for { 23 | select { 24 | case <-ticker.C: 25 | log.WithField("MessageCount", m.MessageCount.Count()).WithField("ClientCount", m.ClientCount.Count()).Info("report") 26 | } 27 | } 28 | } 29 | 30 | func (m *LocalMetrics) GetJson() string { 31 | return fmt.Sprintf("{\"message\":%d,\"client\":%d}", m.MessageCount.Count(), m.ClientCount.Count()) 32 | } 33 | -------------------------------------------------------------------------------- /channel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/websocket" 5 | "sync" 6 | ) 7 | 8 | type Channels struct { 9 | mu sync.Mutex 10 | r map[string][]*websocket.Conn 11 | } 12 | 13 | func (c *Channels) Broadcast(message ChannelMessage) { 14 | clients := c.r[message.Channel] 15 | if len(clients) == 0 { 16 | return 17 | } 18 | for _, client := range clients { 19 | _ = client.WriteMessage(websocket.TextMessage, message.Data) 20 | } 21 | } 22 | 23 | func (c *Channels) AddConn(channel string, conn *websocket.Conn) { 24 | c.mu.Lock() 25 | c.r[channel] = append(c.r[channel], conn) 26 | c.mu.Unlock() 27 | } 28 | 29 | func (c *Channels) RemoveConn(channel string, index int) { 30 | c.mu.Lock() 31 | c.r[channel] = append(c.r[channel][:index], c.r[channel][index+1:]...) 32 | c.mu.Unlock() 33 | } 34 | 35 | var ChannelsRegister = &Channels{ 36 | r: make(map[string][]*websocket.Conn), 37 | } 38 | -------------------------------------------------------------------------------- /doc/USAGE.md: -------------------------------------------------------------------------------- 1 | 2 | ### Usage 3 | 4 | 首先,您需要先将项目克隆本地: 5 | 6 | ``` 7 | git clone https://github.com/Qsnh/go-laravel-broadcast.git 8 | ``` 9 | 10 | 然后您需要编译生成可执行程序: 11 | 12 | ``` 13 | # 安装程序所需要的依赖 14 | go get -v ./... 15 | 16 | # 编译生成可执行程序 17 | go build -o client 18 | ``` 19 | 20 | 接下来,您需要做一些配置: 21 | 22 | ``` 23 | cp .env.example .env 24 | vi .env 25 | ``` 26 | 27 | 其中的 `laravel` 认证不需要修改,默认即可,除非您修改了 `laravel broadcast` 的认证路由。如果您不需要用到 `https` 的话也就无需配置 `https` 相关的信息。 28 | `websocket` 服务参数需要配置下,可能您需要配置下端口和ws的路径,另外为了安全,跨域一定要配置哦,多个域名请用逗号分隔。接下里是 `redis` 的环境配置, 29 | `redis` 的环境配置上是必须的。因为 `laravel broadast` 的 `redis` 驱动是结合 `redis` 的 `sub/pub` 实现的,本程序需要订阅相关的 `redis` 频道来达到推送的目的。 30 | 其中,`subscribe_channels` 是您在 `laravel` 中的 `channel.php` 中定义的频道,注意在频道前面加上 `private` 等修饰符,支持整个表达式。 31 | 32 | ### 数据接口 33 | 34 | + 你可以通过请求 `/metrics` 来获取当前服务运行至今的统计数据,它返回了总计有多少个 client 链接,总结转发了多少个 `redis` 订阅消息,下面是它的返回内容: 35 | 36 | ```json 37 | { 38 | "message": 0, 39 | "client": 0 40 | } 41 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | _ "github.com/joho/godotenv/autoload" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var ( 12 | address = os.Getenv("WEBSOCKET_HOST") + ":" + os.Getenv("WEBSOCKET_PORT") 13 | wsPath = os.Getenv("WEBSOCKET_PATH") 14 | tlsEnabled = os.Getenv("TLS_ENABLED") 15 | tlsKeyFile = os.Getenv("TLS_KEY_FILE") 16 | tlsCertFile = os.Getenv("TLS_CERT_FILE") 17 | ) 18 | 19 | func main() { 20 | // 启动redis 21 | go SubscribeChannel() 22 | go func() { 23 | for message := range SubscribeMessages { 24 | // 收到消息进行推送 25 | go ChannelsRegister.Broadcast(message) 26 | } 27 | }() 28 | // 数据定时输出 29 | go metrics.Report() 30 | // 心跳 31 | go HeartbeatTimer() 32 | // 启动http服务 33 | log.Info(address + wsPath) 34 | http.HandleFunc(wsPath, func(w http.ResponseWriter, r *http.Request) { 35 | NewWebsocket(w, r) 36 | }) 37 | http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { 38 | w.Header().Set("Content-Type", "application/json") 39 | w.Write([]byte(metrics.GetJson())) 40 | }) 41 | var err error 42 | if tlsEnabled == "true" { 43 | // HTTPS 44 | err = http.ListenAndServeTLS(address, tlsCertFile, tlsKeyFile, nil) 45 | } else { 46 | err = http.ListenAndServe(address, nil) 47 | } 48 | if err != nil { 49 | log.WithField("address", address).Fatal("ListenAndServe:", err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /redis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gomodule/redigo/redis" 5 | log "github.com/sirupsen/logrus" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type ChannelMessage struct { 11 | Channel string 12 | Data []byte 13 | } 14 | 15 | var ( 16 | redisHost = os.Getenv("REDIS_HOST") 17 | redisPort = os.Getenv("REDIS_PORT") 18 | redisPassword = os.Getenv("REDIS_PASSWORD") 19 | subscribeChannels = os.Getenv("SUBSCRIBE_CHANNELS") 20 | SubscribeMessages = make(chan ChannelMessage, 100) 21 | ) 22 | 23 | func SubscribeChannel() { 24 | // 连接redis 25 | dailOption := redis.DialPassword(redisPassword) 26 | conn, err := redis.Dial("tcp", redisHost+":"+redisPort, dailOption) 27 | if err != nil { 28 | log.WithField("host", redisHost).WithField("port", redisPort).Fatal(err) 29 | } 30 | defer conn.Close() 31 | // 订阅 32 | psc := redis.PubSubConn{Conn: conn} 33 | if subscribeChannels == "" { 34 | log.Fatal("无订阅频道") 35 | } 36 | channels := strings.Split(subscribeChannels, ",") 37 | for _, channel := range channels { 38 | log.WithField("channel", channel).Info("redis subscribe") 39 | if err := psc.PSubscribe(channel); err != nil { 40 | log.WithField("redis subscribe channel", channel).Error(err) 41 | } 42 | } 43 | // 监听消息 44 | for { 45 | switch v := psc.Receive().(type) { 46 | case redis.Message: 47 | // 统计 48 | metrics.MessageCount.Inc(1) 49 | SubscribeMessages <- ChannelMessage{Channel: v.Channel, Data: v.Data} 50 | //case redis.Subscription: 51 | case error: 52 | return 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | ) 9 | 10 | var ( 11 | authUrl = os.Getenv("AUTH_HOST") + os.Getenv("AUTH_PATH") 12 | ) 13 | 14 | type AuthRes struct { 15 | status bool 16 | body string 17 | } 18 | 19 | func Authorization(channel string, cookies []*http.Cookie, token string) AuthRes { 20 | ar := AuthRes{} 21 | 22 | client := &http.Client{} 23 | url := authUrl + "?channel_name=" + channel 24 | req, err := http.NewRequest("GET", url, nil) 25 | if err != nil { 26 | log.WithField("cookie", cookies).WithField("url", url).Error("init request error.", err) 27 | return ar 28 | } 29 | if token != "" { 30 | req.Header.Add("Authorization", "Bearer "+token) 31 | } 32 | req.Header.Add("Content-Type", "application/json") 33 | for _, cookie := range cookies { 34 | req.AddCookie(cookie) 35 | } 36 | resp, err := client.Do(req) 37 | if err != nil { 38 | log.WithField("cookie", cookies).WithField("url", url).Error("send request error.", err) 39 | return ar 40 | } 41 | defer resp.Body.Close() 42 | 43 | body, err := ioutil.ReadAll(resp.Body) 44 | if err != nil { 45 | log.Error("read auth response content error.", err) 46 | return ar 47 | } 48 | responseBody := string(body) 49 | ar.body = responseBody 50 | log.WithField("cookie", cookies).WithField("status code", resp.StatusCode).WithField("url", url).WithField("content", responseBody).Info("auth response content.") 51 | 52 | if resp.StatusCode == http.StatusOK { 53 | ar.status = true 54 | } 55 | return ar 56 | } 57 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= 3 | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= 4 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 5 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 6 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 7 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 8 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= 9 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a h1:9ZKAASQSHhDYGoxY8uLVpewe1GDZ2vu2Tr/vTdVAkFQ= 12 | github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 13 | github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= 14 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 15 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 16 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 17 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= 18 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 19 | -------------------------------------------------------------------------------- /websocket.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/websocket" 5 | log "github.com/sirupsen/logrus" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | var ( 13 | checkOrigin = os.Getenv("WEBSOCKET_CHECK_ORIGIN") 14 | upgrader = websocket.Upgrader{ 15 | ReadBufferSize: 1024, 16 | WriteBufferSize: 1024, 17 | // 跨域控制 18 | CheckOrigin: func(r *http.Request) bool { 19 | if checkOrigin == "" { 20 | return false 21 | } 22 | if checkOrigin == "*" { 23 | return true 24 | } 25 | origins := strings.Split(checkOrigin, ",") 26 | requestOrigin := r.Header["Origin"][0] 27 | log.WithField("origin", requestOrigin).Info("check origin") 28 | for _, origin := range origins { 29 | if requestOrigin == origin { 30 | return true 31 | } 32 | } 33 | return false 34 | }, 35 | } 36 | ) 37 | 38 | func NewWebsocket(w http.ResponseWriter, r *http.Request) { 39 | conn, err := upgrader.Upgrade(w, r, nil) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | channelName := r.FormValue("channel") 44 | token := r.FormValue("token") 45 | if strings.HasPrefix(channelName, "private-") || strings.HasPrefix(channelName, "presence-") { 46 | authRes := Authorization(channelName, r.Cookies(), token) 47 | if authRes.status == false { 48 | conn.Close() 49 | return 50 | } 51 | if strings.HasPrefix(channelName, "presence-") { 52 | // presence频道在用户加入该频道的时候需要广播给其它用户 53 | SubscribeMessages <- ChannelMessage{channelName, []byte(authRes.body)} 54 | } 55 | } 56 | // 注册channel到连接的映射 57 | ChannelsRegister.AddConn(channelName, conn) 58 | // 统计 59 | metrics.ClientCount.Inc(1) 60 | } 61 | 62 | func HeartbeatTimer() { 63 | t := time.NewTicker(5 * time.Second) 64 | for { 65 | select { 66 | case <-t.C: 67 | go HeartbeatHandler() 68 | } 69 | } 70 | } 71 | 72 | func HeartbeatHandler() { 73 | for channel, conns := range ChannelsRegister.r { 74 | for index, conn := range conns { 75 | if err := conn.WriteMessage(websocket.TextMessage, []byte("hb")); err != nil { 76 | // clear 77 | ChannelsRegister.RemoveConn(channel, index) 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /doc/Private-example.md: -------------------------------------------------------------------------------- 1 | 2 | ## Private Channel Example 3 | 4 | ### 环境 5 | 6 | + Laravel5.8 7 | + Redis 8 | 9 | ### Step 10 | 11 | 首先,修改 `EventServiceProvider` 文件,在 `$listen` 添加: 12 | 13 | ``` 14 | 'App\\Events\\TestPrivateBroadcastEvent' => [ 15 | 'App\\Listeners\\TestPrivateBroadcastListener', 16 | ], 17 | ``` 18 | 19 | 然后执行: 20 | 21 | ``` 22 | php artisan event:generate 23 | ``` 24 | 25 | 紧接着,修改 `TestPrivateBroadcastEvent` 内容如下: 26 | 27 | ```php 28 | 注意这里的频道名,我们定义的是 `App.User.1` ,其真是的频道名是 `private-App.User.1` 。这个后面我们需要用得到。 52 | 53 | 之后,我们在命令行运行: 54 | 55 | ``` 56 | php artisan queue:work 57 | ``` 58 | 59 | 启动队列处理进程。接下来,运行下面命令: 60 | 61 | ``` 62 | ➜ laravel5.8 php artisan tinker 63 | Psy Shell v0.9.9 (PHP 7.1.22 — cli) by Justin Hileman 64 | >>> event(new \App\Events\TestPrivateBroadcastEvent()); 65 | => [ 66 | null, 67 | ] 68 | >>> 69 | ``` 70 | 71 | 然后我们在 redis 的服务可以看到: 72 | 73 | ``` 74 | ➜ ~ docker exec -it redis1 sh 75 | # redis-cli 76 | 127.0.0.1:6379> psubscribe * 77 | Reading messages... (press Ctrl-C to quit) 78 | 1) "psubscribe" 79 | 2) "*" 80 | 3) (integer) 1 81 | 1) "pmessage" 82 | 2) "*" 83 | 3) "private-App.User.1" 84 | 4) "{\"event\":\"App\\\\Events\\\\TestPrivateBroadcastEvent\",\"data\":{\"socket\":null},\"socket\":null}" 85 | ``` 86 | 87 | 没有问题,已经成功的进行了广播。接下来我们启动下 `go-laravel-broadcast` 服务: 88 | 89 | ``` 90 | ➜ laravel-broadcasting git:(master) ✗ ./client 91 | INFO[0000] 0.0.0.0:8890/ws 92 | INFO[0000] redis subscribe channel="private-App.User.*" 93 | ``` 94 | 95 | 我们还需要修改前端的内容,这里我们就在 `welcome.blade.php` 这个文件修改,内容如下: 96 | 97 | ``` 98 | 99 | 100 | 101 | 102 | 103 | Laravel 104 | 105 | 106 | 107 | 126 | 127 | 128 | 129 | ``` 130 | 131 | 我们在 `welcome.blade.php` 创建了一个 websocket 客户端,连接到了 `ws://127.0.0.1:8890/ws?channel=private-App.User.1` 这个地址,其中的 132 | `channel` 的值就是我们前面 event 注册的频道名。我们先启动一个服务: 133 | 134 | ``` 135 | php artisan serve 136 | ``` 137 | 138 | 然后访问 `http://127.0.0.1:8000/`,打开控制台,可以看到: 139 | 140 | ``` 141 | 连接成功 142 | (index):26 连接已关闭... 143 | ``` 144 | 145 | ws 连接被服务器主动关闭,为什么?因为 private 的 channel 需要验证用户的,所以我们需要先登录下,到 `http://127.0.0.1:8000/login` 登录下,在访问首页: 146 | 147 | ``` 148 | 连接成功 149 | 3(index):21 hb 150 | ``` 151 | 152 | 可以看到连接成功,没有被关闭,且受到了 `hb` 的文本消息,这个什么?这是 `go-laravel-server` 的心跳消息哦。 153 | 154 | 接下来,我们在运行下下面的命令: 155 | 156 | ``` 157 | ➜ laravel5.8 php artisan tinker 158 | Psy Shell v0.9.9 (PHP 7.1.22 — cli) by Justin Hileman 159 | >>> event(new \App\Events\TestPrivateBroadcastEvent()); 160 | => [ 161 | null, 162 | ] 163 | >>> 164 | ``` 165 | 166 | 在浏览器的控制台上,我们可以看到: 167 | 168 | ``` 169 | 连接成功 170 | 19(index):21 hb 171 | (index):21 {"event":"App\\Events\\TestPrivateBroadcastEvent","data":{"socket":null},"socket":null} 172 | 2(index):21 hb 173 | ``` 174 | 175 | 收到了 `TestPrivateBroadcastEvent` 的消息了,这样的话,我们就利用 `go-laravel-broadcast` 实现了实时通讯啦。 --------------------------------------------------------------------------------