基於 getty 的分佈式事務框架seata-golang 通信模型詳解

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者 | 劉曉敏 於雨","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"一、簡介","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Java 的世界裏,大家廣泛使用的一個高性能網絡通信框架 netty,很多 RPC 框架都是基於 netty 來實現的。在 golang 的世界裏,","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/AlexStocks/getty","title":""},"content":[{"type":"text","text":"getty","attrs":{}}]},{"type":"text","text":" 也是一個類似 netty 的高性能網絡通信庫。getty 最初由 dubbogo 項目負責人於雨開發,作爲底層通信庫在 [dubbo-go](https://github.com/apache/dubbo-go) 中使用。隨着 dubbo-go 捐獻給 apache 基金會,在社區小夥伴的共同努力下,getty 也最終進入到 apache 這個大家庭,並改名 [dubbo-getty](https://github.com/apache/dubbo-getty) 。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"18 年的時候,我在公司裏實踐微服務,當時遇到最大的問題就是分佈式事務問題。同年,阿里在社區開源他們的分佈式事務解決方案,我也很快關注到這個項目,起初還叫 fescar,後來更名 seata。由於我對開源技術很感興趣,加了很多社區羣,當時也很關注 dubbo-go 這個項目,在裏面默默潛水。隨着對 seata 的瞭解,逐漸萌生了做一個 go 版本的分佈式事務框架的想法。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"要做一個 golang 版的分佈式事務框架,首要的一個問題就是如何實現 RPC 通信。dubbo-go 就是很好的一個例子擺在眼前,遂開始研究 dubbo-go 的底層 getty。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"二、如何基於 getty 實現 RPC 通信","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"getty 框架的整體模型圖如下:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/f3/f3474b7786ea5f6867b0ccf6f709a7a8.png","alt":"image.png","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面結合相關代碼,詳述 seata-golang 的 RPC 通信過程。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"1. 建立連接","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"實現 RPC 通信,首先要建立網絡連接吧,我們從 ","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/apache/dubbo-getty/blob/master/client.go","title":""},"content":[{"type":"text","text":"client.go","attrs":{}}]},{"type":"text","text":" 開始看起。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func (c *client) connect() {\n\tvar (\n\t\terr error\n\t\tss Session\n\t)\n\n\tfor {\n // 建立一個 session 連接\n\t\tss = c.dial()\n\t\tif ss == nil {\n\t\t\t// client has been closed\n\t\t\tbreak\n\t\t}\n\t\terr = c.newSession(ss)\n\t\tif err == nil {\n // 收發報文\n\t\t\tss.(*session).run()\n\t\t\t// 此處省略部分代碼\n \n\t\t\tbreak\n\t\t}\n\t\t// don't distinguish between tcp connection and websocket connection. Because\n\t\t// gorilla/websocket/conn.go:(Conn)Close also invoke net.Conn.Close()\n\t\tss.Conn().Close()\n\t}\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"codeinline","content":[{"type":"text","text":"connect()","attrs":{}}],"attrs":{}},{"type":"text","text":" 方法通過 ","attrs":{}},{"type":"codeinline","content":[{"type":"text","text":"dial()","attrs":{}}],"attrs":{}},{"type":"text","text":" 方法得到了一個 session 連接,進入 dial() 方法:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":""},"content":[{"type":"text","text":"func (c *client) dial() Session {\n\tswitch c.endPointType {\n\tcase TCP_CLIENT:\n\t\treturn c.dialTCP()\n\tcase UDP_CLIENT:\n\t\treturn c.dialUDP()\n\tcase WS_CLIENT:\n\t\treturn c.dialWS()\n\tcase WSS_CLIENT:\n\t\treturn c.dialWSS()\n\t}\n\n\treturn nil\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們關注的是 TCP 連接,所以繼續進入 ","attrs":{}},{"type":"codeinline","content":[{"type":"text","text":"c.dialTCP()","attrs":{}}],"attrs":{}},{"type":"text","text":" 方法:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func (c *client) dialTCP() Session {\n\tvar (\n\t\terr error\n\t\tconn net.Conn\n\t)\n\n\tfor {\n\t\tif c.IsClosed() {\n\t\t\treturn nil\n\t\t}\n\t\tif c.sslEnabled {\n\t\t\tif sslConfig, err := c.tlsConfigBuilder.BuildTlsConfig(); err == nil && sslConfig != nil {\n\t\t\t\td := &net.Dialer{Timeout: connectTimeout}\n\t\t\t\t// 建立加密連接\n\t\t\t\tconn, err = tls.DialWithDialer(d, \"tcp\", c.addr, sslConfig)\n\t\t\t}\n\t\t} else {\n // 建立 tcp 連接\n\t\t\tconn, err = net.DialTimeout(\"tcp\", c.addr, connectTimeout)\n\t\t}\n\t\tif err == nil && gxnet.IsSameAddr(conn.RemoteAddr(), conn.LocalAddr()) {\n\t\t\tconn.Close()\n\t\t\terr = errSelfConnect\n\t\t}\n\t\tif err == nil {\n // 返回一個 TCPSession\n\t\t\treturn newTCPSession(conn, c)\n\t\t}\n\n\t\tlog.Infof(\"net.DialTimeout(addr:%s, timeout:%v) = error:%+v\", c.addr, connectTimeout, perrors.WithStack(err))\n\t\tremotingclient.go","title":""},"content":[{"type":"text","text":"RpcRemotingClient","attrs":{}}]},{"type":"text","text":":","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func (client *RpcRemoteClient) OnOpen(session getty.Session) error {\n\tgo func() \n\t\trequest := protocal.RegisterTMRequest{AbstractIdentifyRequest: protocal.AbstractIdentifyRequest{\n\t\t\tApplicationId: client.conf.ApplicationId,\n\t\t\tTransactionServiceGroup: client.conf.TransactionServiceGroup,\n\t\t}}\n // 建立連接後向 Transaction Coordinator 發起註冊 TransactionManager 的請求\n\t\t_, err := client.sendAsyncRequestWithResponse(session, request, RPC_REQUEST_TIMEOUT)\n\t\tif err == nil {\n // 將與 Transaction Coordinator 建立的連接保存在連接池供後續使用\n\t\t\tclientSessionManager.RegisterGettySession(session)\n\t\t\tclient.GettySessionOnOpenChannel coordinatorevent_listener.go","title":""},"content":[{"type":"text","text":"DefaultCoordinator","attrs":{}}]},{"type":"text","text":":","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"func (coordinator *DefaultCoordinator) OnOpen(session getty.Session) error {\n\tlog.Infof(\"got getty_session:%s\", session.Stat())\n\treturn nil\n}\n\nfunc (coordinator *DefaultCoordinator) OnError(session getty.Session, err error) {\n\t// 釋放 TCP 連接\n SessionManager.ReleaseGettySession(session)\n\tsession.Close()\n\tlog.Errorf(\"getty_session{%s} got error{%v}, will be closed.\", session.Stat(), err)\n}\n\nfunc (coordinator *DefaultCoordinator) OnClose(session getty.Session) {\n\tlog.Info(\"getty_session{%s} is closing......\", session.Stat())\n}\n\nfunc (coordinator *DefaultCoordinator) OnMessage(session getty.Session, pkg interface{}) {\n\tlog.Debugf(\"received message:{%v}\", pkg)\n\trpcMessage, ok := pkg.(protocal.RpcMessage)\n\tif ok {\n\t\t_, isRegTM := rpcMessage.Body.(protocal.RegisterTMRequest)\n\t\tif isRegTM {\n // 將 TransactionManager 信息和 TCP 連接建立映射關係\n\t\t\tcoordinator.OnRegTmMessage(rpcMessage, session)\n\t\t\treturn\n\t\t}\n\n\t\theartBeat, isHeartBeat := rpcMessage.Body.(protocal.HeartBeatMessage)\n\t\tif isHeartBeat && heartBeat == protocal.HeartBeatMessagePing {\n\t\t\tcoordinator.OnCheckMessage(rpcMessage, session)\n\t\t\treturn\n\t\t}\n\n\t\tif rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST ||\n\t\t\trpcMessage.MessageType == protocal.MSGTYPE_RESQUEST_ONEWAY {\n\t\t\tlog.Debugf(\"msgId:%s, body:%v\", rpcMessage.Id, rpcMessage.Body)\n\t\t\t_, isRegRM := rpcMessage.Body.(protocal.RegisterRMRequest)\n\t\t\tif isRegRM {\n // 將 ResourceManager 信息和 TCP 連接建立映射關係\n\t\t\t\tcoordinator.OnRegRmMessage(rpcMessage, session)\n\t\t\t} else {\n\t\t\t\tif SessionManager.IsRegistered(session) {\n\t\t\t\t\tdefer func() {\n\t\t\t\t\t\tif err := recover(); err != nil {\n\t\t\t\t\t\t\tlog.Errorf(\"Catch Exception while do RPC, request: %v,err: %w\", rpcMessage, err)\n\t\t\t\t\t\t}\n\t\t\t\t\t}()\n // 處理事務消息,全局事務註冊、分支事務註冊、分支事務提交、全局事務回滾等\n\t\t\t\t\tcoordinator.OnTrxMessage(rpcMessage, session)\n\t\t\t\t} else {\n\t\t\t\t\tsession.Close()\n\t\t\t\t\tlog.Infof(\"close a unhandled connection! [%v]\", session)\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tresp, loaded := coordinator.futures.Load(rpcMessage.Id)\n\t\t\tif loaded {\n\t\t\t\tresponse := resp.(*getty2.MessageFuture)\n\t\t\t\tresponse.Response = rpcMessage.Body\n\t\t\t\tresponse.Done clientsession_manager.go","title":""},"content":[{"type":"text","text":"getty_client_session_manager.go。","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Transaction Manager 和 Resource Manager 註冊到 Transaction Coordinator 後,一個連接既有可能用來發送 TM 消息也有可能用來發送 RM 消息。我們通過 RpcContext 來標識一個連接信息:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"type RpcContext struct {\n\tVersion string\n\tTransactionServiceGroup string\n\tClientRole meta.TransactionRole\n\tApplicationId string\n\tClientId string\n\tResourceSets *model.Set\n\tSession getty.Session\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"當收到事務消息時,我們需要構造這樣一個 RpcContext 供後續事務處理邏輯使用。所以,我們會構造下列 map 來緩存映射關係:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"go"},"content":[{"type":"text","text":"var (\n\t// session -> transactionRole\n\t// TM will register before RM, if a session is not the TM registered,\n\t// it will be the RM registered\n\tsession_transactionroles = sync.Map{}\n\n\t// session -> applicationId\n\tidentified_sessions = sync.Map{}\n\n\t// applicationId -> ip -> port -> session\n\tclient_sessions = sync.Map{}\n\n\t// applicationId -> resourceIds\n\tclient_resources = sync.Map{}\n)","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這樣,Transaction Manager 和 Resource Manager 分別通過 ","attrs":{}},{"type":"codeinline","content":[{"type":"text","text":"coordinator.OnRegTmMessage(rpcMessage, session)","attrs":{}}],"attrs":{}},{"type":"text","text":" 和 ","attrs":{}},{"type":"codeinline","content":[{"type":"text","text":"coordinator.OnRegRmMessage(rpcMessage, session)","attrs":{}}],"attrs":{}},{"type":"text","text":" 註冊到 Transaction Coordinator 時,會在上述 client","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"sessions map 中緩存 applicationId、ip、port 與 session 的關係,在 client","attrs":{}},{"type":"text","text":"resources map 中緩存 applicationId 與 resourceIds(一個應用可能存在多個 Resource Manager) 的關係。在需要時,我們就可以通過上述映射關係構造一個 RpcContext。這部分的實現和 java 版 seata 有很大的不同,感興趣的可以深入瞭解一下。具體代碼見 ","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/opentrx/seata-golang/blob/dev/tc/server/gettysessionmanager.go","title":""},"content":[{"type":"text","text":"getty_session_manager.go。","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"至此,我們就分析完了 ","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/opentrx/seata-golang","title":""},"content":[{"type":"text","text":"seata-golang","attrs":{}}]},{"type":"text","text":" 整個 RPC 通信模型的機制。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"三、seata-golang 的未來","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https://github.com/opentrx/seata-golang","title":""},"content":[{"type":"text","text":"seata-golang","attrs":{}}]},{"type":"text","text":"  從今年 4 月份開始開發,到 8 月份基本實現和 java 版 [seata 1.2](https://github.com/seata/seata) 協議的互通,對 mysql 數據庫實現了 AT 模式(自動協調分佈式事務的提交回滾),實現了 TCC 模式,TC 端使用 mysql 存儲數據,使 TC 變成一個無狀態應用支持高可用部署。下圖展示了 AT 模式的原理:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/cc/cc5717fe37ec15479d4ff2b44600722a.png","alt":"image20201205-232516.png","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"後續,還有許多工作可以做,比如:對註冊中心的支持、對配置中心的支持、和 java 版 seata 1.4 的協議互通、其他數據庫的支持、raft transaction coordinator 的實現等,希望對分佈式事務問題感興趣的開發者可以加入進來一起來打造一個完善的 golang 的分佈式事務框架。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你有任何疑問,歡迎加入釘釘交流羣 33069364。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另外,歡迎對 dubbogo 感興趣的朋友到 dubbogo 社區釘釘羣(釘釘羣號 31363295)溝通 dubbogo 技術問題。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"*","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}}],"text":"作者簡介","attrs":{}},{"type":"text","text":"*","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"劉曉敏 (GitHubID dk-lockdown),目前就職於 h3c 成都分公司,擅長使用 Java/Go 語言,在雲原生和微服務相關技術方向均有涉獵,目前專攻分佈式事務。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"於雨(github @AlexStocks),dubbo-go 項目和社區負責人,一個有十多年服務端基礎架構研發一線工作經驗的程序員,陸續參與改進過 Muduo/Pika/Dubbo/Sentinel-go 等知名項目,目前在螞蟻金服可信原生部從事容器編排和 service mesh 工作。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"參考資料","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"seata 官方:","attrs":{}},{"type":"link","attrs":{"href":"https://seata.io","title":""},"content":[{"type":"text","text":"https://seata.io","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"java 版 seata:","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/seata/seata","title":""},"content":[{"type":"text","text":"https://github.com/seata/seata","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"seata-golang 項目地址:","attrs":{}},{"type":"link","attrs":{"href":"https://github.com/transaction-wg/seata-golang","title":""},"content":[{"type":"text","text":"https://github.com/opentrx/seata-golang","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"seata-golang go 夜讀 b站分享:","attrs":{}},{"type":"link","attrs":{"href":"https://www.bilibili.com/video/BV1oz411e72T","title":""},"content":[{"type":"text","text":"https://www.bilibili.com/video/BV1oz411e72T","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章