基于 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}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章