goim 文章系列:
0. 背景及動機
繼上一篇文章 goim 架構與定製 , 再談 goim 的定製擴充套件, 這一次談一彈 goim 從 kafka 轉到 nats
github 上的 issue 在這裡github.com/Terry-Mao/g…
簡要說明一下 golang 的 interface: 在 吳德寶AllenWu 文章Golang interface介面深入理解 中這樣寫到:
為什麼要用介面呢?在Gopher China 上的分享中,有大神給出了下面的理由:
writing generic algorithm (類似泛型程式設計)
hiding implementation detail (隱藏具體實現)
providing interception points (提供攔截點-----> 也可稱叫提供 HOOKS , 一個插入其他業務邏輯的鉤子)
換個方式說, interface 就是 de-couple 解耦合在 golang 中的實施, 這是現代程式設計中比較重要的"分層, 解耦合" 架構設計方法
在QQ群"golang中國" 中, 有關於 de-couple 解耦合的話題中, 閃俠這樣說到:
這裡, 就來看看 interface 如何實現 goim 從 kafka 轉到 NATS
1. goim 中的 kafka
看圖, 不說話, 哈哈
上圖中,
- 在 logic 這個網元中, 有 logic 向 kafka 的訊息釋出
- 在 job 網元中, job 從 kafka 訂閱訊息, 再賂 comet 網元分發
那我們的目標很簡單了, 換了!!! ----------> 等等.......能保留原有 kafka 實現不? 在必要時, 可以使用開關項, 切換 nats 或 kafka ??
當然......可以!
2. Don't talk, show me the code!!
下面就比較簡單, 看碼
2.1 釋出介面第一步, 閱讀原始碼
先看原始碼( 注意下面程式碼中的註釋)
程式碼在 github.com/Terry-Mao/g… 大約第33行
// PushMids push a message by mid.
func (l *Logic) PushMids(c context.Context, op int32, mids []int64, msg []byte) (err error) {
keyServers, _, err := l.dao.KeysByMids(c, mids)
if err != nil {
return
}
keys := make(map[string][]string)
for key, server := range keyServers {
if key == "" || server == "" {
log.Warningf("push key:%s server:%s is empty", key, server)
continue
}
keys[server] = append(keys[server], key)
}
for server, keys := range keys {
//
// 主要向 kafka 傳送訊息, 是下面這一行
// l.dao.PushMsg(c, op, server, keys, msg)
// 方法名是 PushMsg
//
if err = l.dao.PushMsg(c, op, server, keys, msg); err != nil {
return
}
}
return
}
複製程式碼
再看一下 dao 是什麼:
程式碼在 github.com/Terry-Mao/g… 大約第20行
// Logic struct
type Logic struct {
c *conf.Config
dis *naming.Discovery
//
//
// 下面這個 dao.Dao 提供了 PushMsg 方法
// 帶個星, 這是個引用
//
//
dao *dao.Dao
// online
totalIPs int64
totalConns int64
roomCount map[string]int32
// load balancer
nodes []*naming.Instance
loadBalancer *LoadBalancer
regions map[string]string // province -> region
}
複製程式碼
最後, 重點來了, 查到 dao 源頭實現
下面是我們需要擴充套件的地方, 在 github.com/Terry-Mao/g…中 dao, 這名稱很 java (DAO-------> Data Access Objects 資料存取物件), 這裡也說明了 bilibili 們在程式碼紡織上, 挺規範
程式碼在 github.com/Terry-Mao/g… 大約第10行開始
// Dao dao.
type Dao struct {
c *conf.Config
//
// ******************************************************************
// 下面這個 kafkaPub 很清楚, 是 kafka 的同步釋出者 kafka.SyncProducer
//
// 這個是我們要換成 interface 的地方
//
// ******************************************************************
//
kafkaPub kafka.SyncProducer
redis *redis.Pool
redisExpire int32
}
// New new a dao and return.
func New(c *conf.Config) *Dao {
d := &Dao{
c: c,
//
// ******************************************************************
// 下面這個 newKafkaPub(c.Kafka) 即是初始化 kafka
// 也就是連線上 kafka
// 下面, 我們先改寫一下這個函式, 變通一下程式碼形式
//
// ******************************************************************
//
kafkaPub: newKafkaPub(c.Kafka),
redis: newRedis(c.Redis),
redisExpire: int32(time.Duration(c.Redis.Expire) / time.Second),
}
return d
}
// 這是連線 kafka 的初化函式( function )
//
func newKafkaPub(c *conf.Kafka) kafka.SyncProducer {
kc := kafka.NewConfig()
kc.Producer.RequiredAcks = kafka.WaitForAll // Wait for all in-sync replicas to ack the message
kc.Producer.Retry.Max = 10 // Retry up to 10 times to produce the message
kc.Producer.Return.Successes = true
pub, err := kafka.NewSyncProducer(c.Brokers, kc)
if err != nil {
panic(err)
}
return pub
}
複製程式碼
這裡, 先小改一下 func New(c *conf.Config) *Dao 這個函式 改成如下程式碼形式
// New new a dao and return.
func New(c *conf.Config) *Dao {
d := &Dao{
c: c,
//
//
// 注意, 下面這行被移出去
// kafkaPub: newKafkaPub(c.Kafka),
//
//
redis: newRedis(c.Redis),
redisExpire: int32(time.Duration(c.Redis.Expire) / time.Second),
}
//
// 變成這樣了, 功能沒變化
//
d.kafkaPub = newKafkaPub(c.Kafka)
return d
}
複製程式碼
2.2 釋出介面第二步, 檢查一下哪個方法( method )需要被 interface 實現
還是看原始碼
程式碼在 github.com/Terry-Mao/g… 大約第13行開始
// PushMsg push a message to databus.
func (d *Dao) PushMsg(c context.Context, op int32, server string, keys []string, msg []byte) (err error) {
pushMsg := &pb.PushMsg{
Type: pb.PushMsg_PUSH,
Operation: op,
Server: server,
Keys: keys,
Msg: msg,
}
b, err := proto.Marshal(pushMsg)
if err != nil {
return
}
//
// ********************************
//
// 實際釋出訊息, 就是下面這個幾行語句
// 1. 組織一下需要傳送的資訊, 以 kafka 的釋出介面要求的形式
// 2. 嘗試釋出資訊, 處理髮布資訊可能的錯誤
//
// 重點注意下面這幾行, 後面會改掉
// 重點注意下面這幾行, 後面會改掉
// 重點注意下面這幾行, 後面會改掉
//
// ********************************
//
m := &sarama.ProducerMessage{
Key: sarama.StringEncoder(keys[0]),
Topic: d.c.Kafka.Topic,
Value: sarama.ByteEncoder(b),
}
if _, _, err = d.kafkaPub.SendMessage(m); err != nil {
log.Errorf("PushMsg.send(push pushMsg:%v) error(%v)", pushMsg, err)
}
return
}
// BroadcastRoomMsg push a message to databus.
func (d *Dao) BroadcastRoomMsg(c context.Context, op int32, room string, msg []byte) (err error) {
pushMsg := &pb.PushMsg{
Type: pb.PushMsg_ROOM,
Operation: op,
Room: room,
Msg: msg,
}
b, err := proto.Marshal(pushMsg)
if err != nil {
return
}
m := &sarama.ProducerMessage{
Key: sarama.StringEncoder(room),
Topic: d.c.Kafka.Topic,
Value: sarama.ByteEncoder(b),
}
//
// ********************************
// 實際釋出訊息, 就是下面這個語句
// ********************************
//
if _, _, err = d.kafkaPub.SendMessage(m); err != nil {
log.Errorf("PushMsg.send(broadcast_room pushMsg:%v) error(%v)", pushMsg, err)
}
return
}
複製程式碼
2.3 換用 interface 實現這個 SendMessage(m) 方法( method )
先上程式碼, 程式碼會說話( golang 簡單就在這裡, 程式碼會說話 ) , 後加說明
// PushMsg interface for kafka / nats
// ******************** 這裡是新加的 interface 定義 *****************
type PushMsg interface {
PublishMessage(topic, ackInbox string, key string, msg []byte) error // ****** 這裡小改了個方法名!!! 注意
Close() error
}
// Dao dao.
type Dao struct {
c *conf.Config
push PushMsg // ******************** 看這裡 *****************
redis *redis.Pool
redisExpire int32
}
// New new a dao and return.
func New(c *conf.Config) *Dao {
d := &Dao{
c: c,
redis: newRedis(c.Redis),
redisExpire: int32(time.Duration(c.Redis.Expire) / time.Second),
}
if c.UseNats { // ******************** 在配置中加一個 bool 布林值的開關項 *****************
d.push = NewNats(c) // ******************** 這裡支援 nats *****************
} else {
d.push = NewKafka(c) //// ******************** 這裡是原來的 kafka *****************
}
return d
}
複製程式碼
kafka 實現 interface 介面的程式碼
// Dao dao.
type kafkaDao struct {
c *conf.Config
push kafka.SyncProducer
}
// New new a dao and return.
func NewKafka(c *conf.Config) *kafkaDao {
d := &kafkaDao{
c: c,
push: newKafkaPub(c.Kafka),
}
return d
}
// PublishMessage push message to kafka
func (d *kafkaDao) PublishMessage(topic, ackInbox string, key string, value []byte) error {
m := &kafka.ProducerMessage{
Key: sarama.StringEncoder(key),
Topic: d.c.Kafka.Topic,
Value: sarama.ByteEncoder(value),
}
_, _, err := d.push.SendMessage(m)
return err
}
複製程式碼
nats 對 interface 的實現
// natsDao dao for nats
type natsDao struct {
c *conf.Config
push *nats.Conn
}
// New new a dao and return.
func NewNats(c *conf.Config) *natsDao {
conn, err := newNatsClient(c.Nats.Brokers, c.Nats.Topic, c.Nats.TopicID)
if err != nil {
return nil
}
d := &natsDao{
c: c,
push: conn,
}
return d
}
// PublishMessage push message to nats
func (d *natsDao) PublishMessage(topic, ackInbox string, key string, value []byte) error {
if d.push == nil {
return errors.New("nats error")
}
msg := &nats.Msg{Subject: topic, Reply: ackInbox, Data: value}
return d.push.PublishMsg(msg)
}
複製程式碼
最後, 呼叫 interface 的變更
// PushMsg push a message to databus.
func (d *Dao) PushMsg(c context.Context, op int32, server string, keys []string, msg []byte) (err error) {
pushMsg := &pb.PushMsg{
Type: pb.PushMsg_PUSH,
Operation: op,
Server: server,
Keys: keys,
Msg: msg,
}
b, err := proto.Marshal(pushMsg)
if err != nil {
return
}
//
// ********************************
//
// 實際釋出訊息, 就是下面這個幾行語句
// 1. 組織一下需要傳送的資訊, 以 kafka 的釋出介面要求的形式
// 2. 嘗試釋出資訊, 處理髮布資訊可能的錯誤
//
// 重點注意下面這幾行, 實際更改
// 重點注意下面這幾行, 實際更改
// 重點注意下面這幾行, 實際更改
//
// ********************************
if err = d.push.PublishMessage(d.c.Kafka.Topic, d.c.Nats.AckInbox, keys[0], b); err != nil {
log.Errorf("PushMsg.send(push pushMsg:%v) error(%v)", pushMsg, err)
}
return
}
複製程式碼
OK, 修改完成
2.4 小結
2.4.1 介面定義 (帶命名的方法集合)
簡明來說, interface 介面定義一下名稱, 再定義介面中要實現的方法 method ( 方法集合 )
// PushMsg interface for kafka / nats
// ******************** 這裡是新加的 interface 定義 *****************
type PushMsg interface {
PublishMessage(topic, ackInbox string, key string, msg []byte) error // ****** 這裡小改了個方法名!!! 注意
Close() error
}
// Dao dao.
type Dao struct {
c *conf.Config
push PushMsg // ******************** 看這裡 *****************
redis *redis.Pool
redisExpire int32
}
複製程式碼
上面 定義了 PushMsg 這個interface , 這是一個 方法( method)集合
2.4.2 方法定義與實現
- 方法名 , 比如 PublishMessage
- input 資料, 就是這些 topic, ackInbox string, key string, msg []byte, 分別是
- topic 這是 kafka 或 nats 裡的主題, 也就是 pub/sub 釋出/訂閱的頻道
- ackInbox 這是 publish 釋出的 confirm 確認頻道
- key 訊息體( payload ) 的鍵
- msg 這是訊息體 payload
- ouput 資料, 這裡是 error , 標示 PublishMessage 方法( method ) 的輸出
這就是一個介面定義, 方法名/ 輸入/ 輸出, 至於方法的具體實現, 交由下面的實體去實現( 可以看 kafka / nats 中分別對應的 PublishMessage 的方法實現)
2.4.3 介面例項化, 以便後面方法呼叫
很清楚, 方法是由具體實現來完成, 下面就是例項化方法
是用哪一個具體實現呢, 就看例項化哪一個了, interface 最終落地, 就在這裡
if c.UseNats { // ******************** 在配置中加一個 bool 布林值的開關項 *****************
d.push = NewNats(c) // ******************** 這裡支援 nats *****************
} else {
d.push = NewKafka(c) //// ******************** 這裡是原來的 kafka *****************
}
複製程式碼
而在 func (d *Dao) PushMsg(c context.Context, op int32, server string, keys []string, msg []byte) (err error) 中, 則簡單呼叫 interface 定義的方法
2.4.4 介面方法呼叫
與其他方法 method 或函式 function 是一樣的, 沒什麼特別的
// ********************************
if err = d.push.PublishMessage(d.c.Kafka.Topic, d.c.Nats.AckInbox, keys[0], b); err != nil {
log.Errorf("PushMsg.send(push pushMsg:%v) error(%v)", pushMsg, err)
}
複製程式碼
3. 淺談 golang 的 interface --> 解耦合!!
再一次回看,
在 吳德寶AllenWu 文章Golang interface介面深入理解 中這樣寫到:
為什麼要用介面呢?在Gopher China 上的分享中,有大神給出了下面的理由:
writing generic algorithm (類似泛型程式設計)
hiding implementation detail (隱藏具體實現)
providing interception points (提供攔截點-----> 也可稱叫提供 HOOKS , 一個插入其他業務邏輯的鉤子)
interface 確是隱藏了具體實現, 能讓我們很容易的把 goim 對 kafka 的依賴, 切換到 nats , 並且通過一個開關項, 來確定使用哪一個具體實現
擴充套件一下, 這個 interface 也可以實現從 kafka 切換到 rabbitMQ / activeMQ / redis (pub/sub) .... 只要簡單實現 PushMsg 這個 interface 就好啦
4. 原始碼及其他補充
另有 goim 在 job 網元上的 subscribe 訂閱介面, 支援 interface 程式碼是一路子方法, 直接看原始碼吧, 有交流討論再另寫.
注: job 程式碼中, 我把某個方法( method ) 拆解成了函式( function ), 有興趣的朋友可以查一下, 有些小區別,但效果一樣.
goim 原始碼在github.com/Terry-Mao/g…
我寫的程式碼在github.com/tsingson/go…
下面是 2019/04/23 補充內容:
經網上交流, 另一位朋友 weisd 改寫的 goim, 支援 nsq 的 interface, 程式碼組織得比我好啊:
- 支援 nsql 作為 kafka 替代
- 程式碼獨立了一個 brocker , 封裝得很不錯
程式碼在這裡 github.com/weisd/goim
5. 擴充套件, 看看 gRPC 中的解耦合
gRPC , 就是 google 的 RPC ( Remote Procedure Call) , 看一下 gRPC 以 go 實現的 interface 定義
5.1 先看原始的 protobuf 定義
protobuf 是 gRPC 中預設的 介面定義, 就像 愛立信 ICE ( 開源版本是 zeroICE ) 的 slice , apache 的 thrift
在 goim 中, 網元間用 gRPC 通訊, 再看圖
看圖上的 grpc 標示, 注意, 圖上標示箭頭不完全準確:grpc 同時支援
- 普通 Client / Server 呼叫(北向)介面
- Client 向 Server 的流式(北向)流式介面
- Server 向 Cinet 呼叫(南向)流式介面
- 以及 Server / Client 雙向流式介面
網上文章很多, 不一一展開了. 我們重點關注一下, golang 中對 gRPC 的實現, 也就是 golang 如何把 protobuf 定義的介面, 定義為 golang 中的 interface , 以及如何具體實現 interface .
看碼, 看碼, 看碼:
syntax = "proto3";
package goim.comet;
option go_package = "grpc";
//......
//
// ************************
// 這裡定義 input 輸入
message PushMsgReq {
repeated string keys = 1;
int32 protoOp = 3;
Proto proto = 2;
}
//
// ************************
// 這裡定義 output 輸出
message PushMsgReply {}
//.........
service Comet {
// ..........
//PushMsg push by key or mid
//
// ************************
// 這裡定義介面, 這個介面可以由
// golang / java / rust / js / python / php ...實現
//
// 這是解耦合的極致啊!!!!!!!!!!!!!!!!
//
// ************************
//
rpc PushMsg(PushMsgReq) returns (PushMsgReply);
// Broadcast send to every enrity
// ...........
}
複製程式碼
5.2 gRPC 中 go 實現的 interface 定義
注意, 下面的原始碼是 protobuf 自動生成的, 不需要編輯更改, 註釋是方便溝通額外加的
// Server API for Comet service
// ************************
// 這裡定義介面, golang 實現伺服器端
// ************************
type CometServer interface {
...
// PushMsg push by key or mid
//
// ************************
// 這裡定義介面, golang 的介面中的方法
// ************************
//
PushMsg(context.Context, *PushMsgReq) (*PushMsgReply, error)
...
}
複製程式碼
5.3 gRPC 中 go 實現的 interface 例項化
最後, 具體例項化程式碼實現, 在
程式碼會說話兒, 這裡就不展示了.
6. 鄭重警告
謝謝朋友們看到最後, 寫碼掙錢的朋友都是有一說一, 這裡宣告一下:
程式碼中把 kafka 寫成可用 nats 替換, 只是技術上的學習與嘗試, 並不是建議或推薦使用 nats:
- nats 並不保障訊息送達
- nats 並不提供持久化
- nats 用在 goim 上的效率, 還需要壓測
所以, case by case , 具體業務場景具體分析, 商用專案的選型, 是一個慎重而嚴謹的事兒
請自行評估風險/成本
.
.
感謝 www.bilibili.com & 毛劍 及眾多開源社群的朋友們
歡迎交流與批評..... .
7. 補充
有朋友問了些不太相關問題, 公開加一下:
- golang 的編輯/ IDE 我用 jetbrains goland , 程式碼重構最是省時省腦, 我是JB 全家桶付費使用者, 不解釋
- 流程圖用 omnigraffle, 號稱蘋果上的 visio
- 本機除錯用 docker
- 有關架構設計中的介面, 請參考 面向介面程式設計 / IOC (Inversion Of Control) 控制反轉 / 以及 DIP (Dependency inversion principle) 依賴倒置, 網上資料很多, 個人認為是 java 精華所在 (注:近2年我不寫 java 了, 有關java的事, 高人很多)
發一張老圖兒(幾年前的專案了), omnigraffle 畫的, 這軟體挺好用( 只有 mac 版本 )
關於我
網名 tsingson (三明智, 江湖人稱3爺)
原 ustarcom IPTV/OTT 事業部播控產品線技術架構溼/解決方案工程溼角色(8年), 自由職業者,
喜歡音樂(口琴,是第三/四/五屆廣東國際口琴嘉年華的主策劃人之一), 攝影與越野,
喜歡 golang 語言 (商用專案中主要用 postgres + golang )