環境

後端

語言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如何實現定時輪詢

喜歡可以關注微信公眾號:

golang開發的聊天室