golang開發的聊天室
環境
後端
語言golang
資料庫elastisearch
web框架是基於gin封裝的
websocket庫用的是gorilla/websocket
日誌: zap
Error: pkg/errors
前端
框架: react
服務端渲染: nextjs
UI: Material UI
登入認證: cookie nookie
websocket是瀏覽器原生支援
後續聊天功能模仿:https://getstream。io/
登入
github OAuth documentation
google Sign-In for Websites
體驗地址:
PC體驗較好,h5只是做了一個評論元件。支援Github/Google登入,只用作登入。有人建議我放開登入,但是我相信有耐心看文字的人,也會有實際點選體驗的人
如何建立連線
websocket協議
websocket複用http的握手通道,客戶端透過HTTP請求與WebSocket服務端協商升級協議,協議升級完成後,後續的資料交換則按按照websocket的協議。
1. 客戶端:申請協議升級
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: m3xNMIzhueJyd3N66EAK6w==
Sec-WebSocket-Version: 13
Upgrade: websocket
Connection: Upgrade
表示要升級協議
Upgrade: websocket
升級到websocket協議
Sec-WebSocket-Version: 13
websocket協議版本,如果服務端不支援該版本,需要返回一個
Sec-WebSocket-Version
header,裡面包含服務端支援的版本號。
Sec-WebSocket-Key: m3xNMIzhueJyd3N66EAK6w==
與後面服務端響應首部的
Sec-WebSocket-Accept
是配套的,提供基本的防護,比如惡意的連線,或者無意的連線
2. 服務端:響應協議升級
HTTP
/
1。1
101
Switching Protocols
Connection
:
upgrade
Sec-WebSocket-Accept
:
qgWIKoKflyd9H8L/jCNa8XP4CYQ=
Upgrade
:
websocket
狀態程式碼
101
表示協議切換。到此完成協議升級,後續的資料互動都按照新的協議來。
3. Sec-WebSocket-Accept的計算
Sec-WebSocket-Accept
根據客戶端請求首
Sec-WebSocket-Accept
根據客戶端請求首部的
Sec-WebSocket-Key
計算出來。
計算公式為:
將
Sec-WebSocket-Key
跟
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接。
透過SHA1計算出摘要,並轉成base64字串。
虛擬碼如下:
>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
驗證下前面的返回結果:
const
crypto
=
require
(
‘crypto’
);
const
magic
=
‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11’
;
const
secWebSocketKey
=
‘m3xNMIzhueJyd3N66EAK6w==’
;
let
secWebSocketAccept
=
crypto
。
createHash
(
‘sha1’
)
。
update
(
secWebSocketKey
+
magic
)
。
digest
(
‘base64’
);
console
。
log
(
secWebSocketAccept
);
// qgWIKoKflyd9H8L/jCNa8XP4CYQ=
前端難點
服務端渲染框架如何使用cookie
nextjs是服務端渲染框架,nextjs會請求是在伺服器上初始化一次dom,要獲取cookie可以有2種方式
從請求request中獲取cookie,返回給元件
客戶端請求完成後用hook從瀏覽器本地載入cookie
cookie 庫用的是 nookies
React
。
useEffect
(()
=>
{
const
all
=
parseCookies
();
if
(
all
。
douyacun
)
{
const
douyacun
=
JSON
。
parse
(
all
。
douyacun
);
setCook
(
douyacun
);
}
},
[]);
另一個就是本地開發cookie跨域的問題, 本地域名localhost:3000,服務端域名localhost:9000:
本來是想用http-proxy-middleware 來解決跨域的問題,但是websocket代理關於cookie的問題難以搞定,版本是1。0。3
最後還是nginx了,怎麼統一一個域名呢?location,後端服務統一以api開頭
server
{
listen
80
;
server_name
douyacun。io
;
location
/api/
{
if
(
$request_method
=
‘OPTIONS’)
{
return
204
;
}
proxy_set_header
X-Real-IP
$remote_addr
;
proxy_set_header
X-Forwarded-For
$proxy_add_x_forwarded_for
;
proxy_set_header
Host
$http_host
;
proxy_set_header
X-Nginx-Proxy
true
;
proxy_http_version
1
。1
;
proxy_set_header
Upgrade
$http_upgrade
;
proxy_set_header
Connection
‘upgrade’
;
# 不需要考慮到負載的,就無需配置upstream節點。
proxy_pass
http://127。0。0。1:9003
;
}
location
/
{
proxy_set_header
X-Real-IP
$remote_addr
;
proxy_set_header
X-Forwarded-For
$proxy_add_x_forwarded_for
;
proxy_set_header
Host
$http_host
;
proxy_set_header
X-Nginx-Proxy
true
;
proxy_set_header
Connection
“”
;
# 不需要考慮到負載的,就無需配置upstream節點。
proxy_pass
http//127。0。0。1:3000
;
}
}
修改本地dns
http://
douyacun。io
=> 127。0。0。1
如何保證websocket初始化一次
還是因為框架用的nextjs服務端渲染框架,不能在全域性初始化websocket,只能在useEffect中使用
useEffect
(()
=>
{
initWebsocket
();
return
()
=>
{
}
});
const
initWebsocket
=
()
=>
{
conn
=
new
WebSocket
(
WS_ADDRESS
);
conn
。
onmessage
=
handlerMessage
;
conn
。
onclose
=
function
()
{
dispatch
({
type
:
“dialog_open”
,
dialogOpen
:
true
})
};
conn
。
onerror
=
function
()
{
console
。
log
(
“連線失敗”
);
}
}
這樣會有一個問題,只要有state變更,useEffect就會被呼叫,導致webSocket迴圈初始化
todo: 後端服務如何識別這種重複性的初始化,如何拒絕?
react hook 文件給出了答案
the
previous effect is cleaned up before executing the next effect
。 In our example, this means a new subscription is created on every update。 To avoid firing an effect on every update, refer to the next section。
意味著元件的每一次更新都會建立新的訂閱
If you pass an empty array (
[]
), the props and state inside the effect will always have their initial values,
useEffect第二個引數傳 [], 就不回每次state變更都呼叫了。
useEffect
(()
=>
{
initWebsocket
();
return
()
=>
{
}
},
[]);
conn.onmessage(() => {setMessages()}) 不會觸發state元件的重新渲染?
這個問題沒有弄懂,不過react hook提供另一種狀態管理,useReducer(), 就像redux一樣。
state 邏輯較複雜且包含多個子值,或者下一個 state 依賴於之前的 state 等。並且,使用
useReducer
還能給那些會觸發深更新的元件做效能最佳化,
滑動條的位置
收到新訊息時,滑動條回到最底部
hook 提供了useRef可以操作元件, 在收到訊息時,控制滑動條到最底部,
const scrollToBottom = () => {
// 保證訊息在最下面
contentRef。current。scrollTop = contentRef。current。scrollHeight
}
const handlerMessage = function (evt) {
let msg = JSON。parse(evt。data)
dispatch({ type: “new_message”, msg: msg, channel_id: msg[‘channel_id’] });
scrollToBottom();
}
。。。
上拉載入更多歷史訊息
滑動條高度 = 載入歷史訊息完成後文件高度 - 載入前文件高度
// 這裡是為了在載入完成後,在控制滑動條位置,如果不是hook的話,setState(state, () => {}),第二個引數回掉函式完成,hook使用useEffect的代替了這種方式
useEffect(() => {
// loadMore 是請求後端的介面
let t = setTimeout(loadMore, 1000);
if (!loading) {
// 滑動條高度 = 載入歷史訊息完成後文件高度 - 載入前文件高度
contentRef。current。scrollTop = contentRef。current。scrollHeight - messagesHeight;
}
return () => {
clearTimeout(t);
}
}, [loading]);
// contentRef。onScroll
const upScrollLoadMore = () => {
let scrollTop = contentRef。current。scrollTop;
// 記錄載入前文件的高度
let channelMessages = state。messages[state。currentId];
if (scrollTop == 0 && !loading && channelMessages && channelMessages。length > 0) {
let channel = getChannelById(state。currentId);
if (channel。total > 0) {
setloading(true);
}
}
}
正在檢視歷史訊息,如果有新訊息來會導致滑動條到最底部
todo::
nginx如何反向代理websocket?
前端開發環境配置的時候已經展示過一次
proxy_http_version 1。1;
: 代理http版本號
proxy_set_header Upgrade $http_upgrade;
: http協議升級的請求頭Upgrade傳給後端
proxy_set_header Connection ‘upgrade’;
http協議升級的請求頭Connection傳給後端
群聊和私聊是如何實現的
不管是私聊還是群聊,都會建立一個channel來包含成員,每次訊息傳送訊息指定channel。id即可,伺服器在收到訊息後,根據channel。id向channel。members推送訊息。
channel分了3中型別:
global 全域性,每個人都會訂閱
public 群聊 聊天人數超過2個
private 私聊 聊天人群2個
一個連線的成本是多少?
這邊使用gorilla/websocket 1萬個連線測試
佔用147。93MB RAM, 平均連線每個佔用15kb 測試程式碼見:github gwebsocket
(pprof) top
Showing nodes accounting for 137。93MB, 93。24% of 147。93MB total
Dropped 6 nodes (cum <= 0。74MB)
Showing top 10 nodes out of 51
flat flat% sum% cum cum%
73。79MB 49。88% 49。88% 73。79MB 49。88% bufio。NewWriterSize
34。63MB 23。41% 73。29% 34。63MB 23。41% bufio。NewReaderSize
11MB 7。44% 80。73% 11MB 7。44% runtime。malg
4MB 2。70% 83。44% 5。50MB 3。72% net/textproto。(*Reader)。ReadMIMEHeader
3MB 2。03% 85。46% 3。50MB 2。37% github。com/gorilla/websocket。newConn
3MB 2。03% 87。49% 10。50MB 7。10% net/http。readRequest
2。50MB 1。69% 89。18% 16。50MB 11。16% net/http。(*conn)。readRequest
2。50MB 1。69% 90。87% 3。50MB 2。37% context。propagateCancel
2MB 1。35% 92。23% 2MB 1。35% syscall。anyToSockaddr
1。50MB 1。01% 93。24% 1。50MB 1。01% net。newFD
(pprof) web
failed to execute dot。 Is Graphviz installed? Error: exec: “dot”: executable file not found in $PATH
(pprof) list flat
Total: 147。93MB
goroutine是10003,每個goroutine佔用4kb的記憶體
(pprof) top
Showing nodes accounting for 10001, 100% of 10003 total
Dropped 24 nodes (cum <= 50)
Showing top 10 nodes out of 19
flat flat% sum% cum cum%
10001 100% 100% 10001 100% runtime。gopark
0 0% 100% 9998 100% bufio。(*Reader)。Peek
0 0% 100% 9998 100% bufio。(*Reader)。fill
0 0% 100% 9999 100% github。com/gorilla/websocket。(*Conn)。NextReader
0 0% 100% 9999 100% github。com/gorilla/websocket。(*Conn)。ReadMessage
0 0% 100% 9999 100% github。com/gorilla/websocket。(*Conn)。advanceFrame
0 0% 100% 9998 100% github。com/gorilla/websocket。(*Conn)。read
0 0% 100% 9999 100% internal/poll。(*FD)。Read
0 0% 100% 10001 100% internal/poll。(*pollDesc)。wait
0 0% 100% 10001 100% internal/poll。(*pollDesc)。waitRead (inline)
(pprof) list flat
Total: 10003
(pprof)
根據 Eran Yanay 在 Gophercon Israel 分享的講座
https://www。
youtube。com/watch?
reload=9&v=LI1YTFMi8W4
最佳化, 程式碼在github
使用epoll最佳化, 複用goroutine, goroutine適合cpu密集型,而epoll適合I/O密集型,這裡使用epoll來複用goroutine, 如果是1萬個連結的話, 4kb * 10000 / 1024 ~= 39M , epoll的原理和用法可以看一下,瞭解一下高大上的epoll
這邊使用epoll 記憶體節省了 147。93 - 79。94 = 67。99MB,
(pprof) top
Showing nodes accounting for 79。94MB, 100% of 79。94MB total
Showing top 10 nodes out of 37
flat flat% sum% cum cum%
38。65MB 48。35% 48。35% 38。65MB 48。35% bufio。NewReaderSize (inline)
30。12MB 37。67% 86。02% 30。12MB 37。67% bufio。NewWriterSize
4。50MB 5。63% 91。65% 5MB 6。26% github。com/gorilla/websocket。newConn
2。50MB 3。13% 94。78% 2。50MB 3。13% net。sockaddrToTCP
2MB 2。50% 97。28% 2MB 2。50% syscall。anyToSockaddr
0。67MB 0。84% 98。12% 0。67MB 0。84% main。(*epoll)。Add
0。50MB 0。63% 98。75% 0。50MB 0。63% fmt。(*pp)。handleMethods
0。50MB 0。63% 99。37% 0。50MB 0。63% net。newFD
0。50MB 0。63% 100% 0。50MB 0。63% github。com/gorilla/websocket。(*Conn)。SetPingHandler
0 0% 100% 38。65MB 48。35% bufio。NewReader
(pprof) list flat
Total: 79。94MB
執行的goroutine只有5個
(pprof) top
Showing nodes accounting for 5, 100% of 5 total
Showing top 10 nodes out of 35
flat flat% sum% cum cum%
2 40。00% 40。00% 2 40。00% runtime。gopark
1 20。00% 60。00% 1 20。00% runtime/pprof。writeRuntimeProfile
1 20。00% 80。00% 1 20。00% syscall。Syscall
1 20。00% 100% 1 20。00% syscall。Syscall6
0 0% 100% 2 40。00% internal/poll。(*FD)。Accept
0 0% 100% 1 20。00% internal/poll。(*FD)。Read
0 0% 100% 2 40。00% internal/poll。(*pollDesc)。wait
0 0% 100% 2 40。00% internal/poll。(*pollDesc)。waitRead (inline )
0 0% 100% 2 40。00% internal/poll。runtime_pollWait
0 0% 100% 1 20。00% main。(*epoll)。Wait
(pprof) list flat
Total: 5
從上面記憶體佔用情況來看還是buf佔用記憶體比較多,接下來就考慮最佳化buf的使用。go websocket庫有2個比較推薦,第一個就是上面一直用的另一個是:gobwas/ws, gobwas有幾個特性:
Zero-copy upgrade
No intermediate allocations during I/O
Low-level API which allows to build your own logic of packet handling and buffers reuse
High-level wrappers and helpers around API in
wsutil
package, which allow to start fast without digging the protocol internals
1w個連結3M記憶體, 5個goroutine
(
pprof
)
top
Showing
nodes
accounting
for
3328
。
46kB
,
100
%
of
3328
。
46kB
total
Showing
top
10
nodes
out
of
20
flat
flat
%
sum
%
cum
cum
%
1024
。
12kB
30
。
77
%
30
。
77
%
1024
。
12kB
30
。
77
%
net
。
newFD
(
inline
)
1024
。
05kB
30
。
77
%
61
。
54
%
1024
。
05kB
30
。
77
%
net
。
sockaddrToTCP
768
。
26kB
23
。
08
%
84
。
62
%
768
。
26kB
23
。
08
%
main
。(*
epoll
)。
Add
512
。
03kB
15
。
38
%
100
%
512
。
03kB
15
。
38
%
syscall
。
anyToSockaddr
0
0
%
100
%
512
。
03kB
15
。
38
%
internal
/
poll
。(*
FD
)。
Accept
0
0
%
100
%
512
。
03kB
15
。
38
%
internal
/
poll
。
accept
0
0
%
100
%
2560
。
20kB
76
。
92
%
main
。
main
0
0
%
100
%
768
。
26kB
23
。
08
%
main
。
wsHandle
0
0
%
100
%
2560
。
20kB
76
。
92
%
net
。(*
TCPListener
)。
Accept
0
0
%
100
%
2560
。
20kB
76
。
92
%
net
。(*
TCPListener
)。
accept
(
pprof
)
list
flat
Total
:
3
。
25MB
(
pprof
)
top
Showing
nodes
accounting
for
5
,
100
%
of
5
total
Showing
top
10
nodes
out
of
35
flat
flat
%
sum
%
cum
cum
%
2
40
。
00
%
40
。
00
%
2
40
。
00
%
runtime
。
gopark
1
20
。
00
%
60
。
00
%
1
20
。
00
%
runtime
/
pprof
。
writeRuntimeProfile
1
20
。
00
%
80
。
00
%
1
20
。
00
%
syscall
。
Syscall
1
20
。
00
%
100
%
1
20
。
00
%
syscall
。
Syscall6
0
0
%
100
%
2
40
。
00
%
internal
/
poll
。(*
FD
)。
Accept
0
0
%
100
%
1
20
。
00
%
internal
/
poll
。(*
FD
)。
Read
0
0
%
100
%
2
40
。
00
%
internal
/
poll
。(*
pollDesc
)。
wait
0
0
%
100
%
2
40
。
00
%
internal
/
poll
。(*
pollDesc
)。
waitRead
(
inline
)
0
0
%
100
%
2
40
。
00
%
internal
/
poll
。
runtime_pollWait
0
0
%
100
%
1
20
。
00
%
main
。(*
epoll
)。
Wait
(
pprof
)
list
flat
Total
:
5
heartbeat???
使用gobws重構了一下聊天室,每隔一段時間,如果不發訊息連線會自動斷開,問題定位在nginx上,代理超時的機制,2次讀之間的超時。
server
{
listen
80
;
server_name
douyacun。io
;
location
/api/
{
if
(
$request_method
=
‘OPTIONS’)
{
return
204
;
}
proxy_set_header
X-Real-IP
$remote_addr
;
proxy_set_header
X-Forwarded-For
$proxy_add_x_forwarded_for
;
proxy_set_header
Host
$http_host
;
proxy_set_header
X-Nginx-Proxy
true
;
proxy_http_version
1
。1
;
proxy_set_header
Upgrade
$http_upgrade
;
proxy_set_header
Connection
‘upgrade’
;
# 不需要考慮到負載的,就無需配置upstream節點。
proxy_pass
http://127。0。0。1:9003
;
}
location
/
{
proxy_set_header
X-Real-IP
$remote_addr
;
proxy_set_header
X-Forwarded-For
$proxy_add_x_forwarded_for
;
proxy_set_header
Host
$http_host
;
proxy_set_header
X-Nginx-Proxy
true
;
proxy_set_header
Connection
“”
;
# 不需要考慮到負載的,就無需配置upstream節點。
proxy_pass
http://127。0。0。1:3000
;
}
}
nginx有2個引數是涉及到timeout
proxy_read_timeout 預設值 60s 該指令設定與代理伺服器的讀超時時間。它決定了nginx會等待多長時間來獲得請求的響應。 這個時間不是獲得整個response的時間,而是兩次reading操作的時間。
proxy_send_timeout 預設值 60s 這個指定設定了傳送請求給upstream伺服器的超時時間。超時設定不是為了整個傳送期間,而是在兩次write操作期間。 如果超時後,upstream沒有收到新的資料,nginx會關閉連線
如何解決
?
透過定期傳送ping幀以保持連線並確認連線是否還在使用
定時給scoket傳送ping訊息?
如果每個connection持有一個goroutine的話,初始化一個ticker定時器每隔N秒傳送一次ping就ok了,問題是我們使用epoll來節省goroutine
TODO:: epoll如何實現定時輪詢
喜歡可以關注微信公眾號: