分布式事务框架 seata-golang 通信模型详解
作者 | 劉曉敏 于雨
一、簡(jiǎn)介
Java 的世界里,大家廣泛使用的一個(gè)高性能網(wǎng)絡(luò)通信框架 netty,很多 RPC 框架都是基于 netty 來(lái)實(shí)現(xiàn)的。在 golang 的世界里,getty 也是一個(gè)類似 netty 的高性能網(wǎng)絡(luò)通信庫(kù)。getty 最初由 dubbogo 項(xiàng)目負(fù)責(zé)人于雨開(kāi)發(fā),作為底層通信庫(kù)在 dubbo-go 中使用。隨著 dubbo-go 捐獻(xiàn)給 apache 基金會(huì),在社區(qū)小伙伴的共同努力下,getty 也最終進(jìn)入到 apache 這個(gè)大家庭,并改名 dubbo-getty 。
18 年的時(shí)候,我在公司里實(shí)踐微服務(wù),當(dāng)時(shí)遇到最大的問(wèn)題就是分布式事務(wù)問(wèn)題。同年,阿里在社區(qū)開(kāi)源他們的分布式事務(wù)解決方案,我也很快關(guān)注到這個(gè)項(xiàng)目,起初還叫 fescar,后來(lái)更名 seata。由于我對(duì)開(kāi)源技術(shù)很感興趣,加了很多社區(qū)群,當(dāng)時(shí)也很關(guān)注 dubbo-go 這個(gè)項(xiàng)目,在里面默默潛水。隨著對(duì) seata 的了解,逐漸萌生了做一個(gè) go 版本的分布式事務(wù)框架的想法。
要做一個(gè) golang 版的分布式事務(wù)框架,首要的一個(gè)問(wèn)題就是如何實(shí)現(xiàn) RPC 通信。dubbo-go 就是很好的一個(gè)例子擺在眼前,遂開(kāi)始研究 dubbo-go 的底層 getty。
二、如何基于 getty 實(shí)現(xiàn) RPC 通信
getty 框架的整體模型圖如下:
下面結(jié)合相關(guān)代碼,詳述 seata-golang 的 RPC 通信過(guò)程。
1. 建立連接
實(shí)現(xiàn) RPC 通信,首先要建立網(wǎng)絡(luò)連接吧,我們從 client.go 開(kāi)始看起。
func (c *client) connect() {var (err errorss Session)for {// 建立一個(gè) session 連接ss = c.dial()if ss == nil {// client has been closedbreak}err = c.newSession(ss)if err == nil {// 收發(fā)報(bào)文ss.(*session).run()// 此處省略部分代碼break}// don't distinguish between tcp connection and websocket connection. Because// gorilla/websocket/conn.go:(Conn)Close also invoke net.Conn.Close()ss.Conn().Close()} }connect() 方法通過(guò) dial() 方法得到了一個(gè) session 連接,進(jìn)入 dial() 方法:
func (c *client) dial() Session {switch c.endPointType {case TCP_CLIENT:return c.dialTCP()case UDP_CLIENT:return c.dialUDP()case WS_CLIENT:return c.dialWS()case WSS_CLIENT:return c.dialWSS()}return nil }我們關(guān)注的是 TCP 連接,所以繼續(xù)進(jìn)入 c.dialTCP() 方法:
func (c *client) dialTCP() Session {var (err errorconn net.Conn)for {if c.IsClosed() {return nil}if c.sslEnabled {if sslConfig, err := c.tlsConfigBuilder.BuildTlsConfig(); err == nil && sslConfig != nil {d := &net.Dialer{Timeout: connectTimeout}// 建立加密連接conn, err = tls.DialWithDialer(d, "tcp", c.addr, sslConfig)}} else {// 建立 tcp 連接conn, err = net.DialTimeout("tcp", c.addr, connectTimeout)}if err == nil && gxnet.IsSameAddr(conn.RemoteAddr(), conn.LocalAddr()) {conn.Close()err = errSelfConnect}if err == nil {// 返回一個(gè) TCPSessionreturn newTCPSession(conn, c)}log.Infof("net.DialTimeout(addr:%s, timeout:%v) = error:%+v", c.addr, connectTimeout, perrors.WithStack(err))<-wheel.After(connectInterval)} }至此,我們知道了 getty 如何建立 TCP 連接,并返回 TCPSession。
2. 收發(fā)報(bào)文
那它是怎么收發(fā)報(bào)文的呢,我們回到 connection 方法接著往下看,有這樣一行 ss.(*session).run(),在這行代碼之后代碼都是很簡(jiǎn)單的操作,我們猜測(cè)這行代碼運(yùn)行的邏輯里面一定包含收發(fā)報(bào)文的邏輯,接著進(jìn)入 run() 方法:
func (s *session) run() {// 省略部分代碼go s.handleLoop()go s.handlePackage() }
這里起了兩個(gè) goroutine,handleLoop 和 handlePackage,看字面意思符合我們的猜想,進(jìn)入 handleLoop() 方法:
通過(guò)上面的代碼,我們不難發(fā)現(xiàn),handleLoop() 方法處理的是發(fā)送報(bào)文的邏輯,RPC 需要發(fā)送的消息首先由 s.writer 編碼成二進(jìn)制比特,然后通過(guò)建立的 TCP 連接發(fā)送出去。這個(gè) s.writer 對(duì)應(yīng)的 Writer 接口是 RPC 框架必須要實(shí)現(xiàn)的一個(gè)接口。
繼續(xù)看 handlePackage() 方法:
func (s *session) handlePackage() {// 省略部分代碼if _, ok := s.Connection.(*gettyTCPConn); ok {if s.reader == nil {errStr := fmt.Sprintf("session{name:%s, conn:%#v, reader:%#v}", s.name, s.Connection, s.reader)log.Error(errStr)panic(errStr)}err = s.handleTCPPackage()} else if _, ok := s.Connection.(*gettyWSConn); ok {err = s.handleWSPackage()} else if _, ok := s.Connection.(*gettyUDPConn); ok {err = s.handleUDPPackage()} else {panic(fmt.Sprintf("unknown type session{%#v}", s))} }進(jìn)入 handleTCPPackage() 方法:
func (s *session) handleTCPPackage() error {// 省略部分代碼conn = s.Connection.(*gettyTCPConn)for {// 省略部分代碼bufLen = 0for {// for clause for the network timeout condition check// s.conn.SetReadTimeout(time.Now().Add(s.rTimeout))// 從 TCP 連接中收到報(bào)文bufLen, err = conn.recv(buf)// 省略部分代碼break}// 省略部分代碼// 將收到的報(bào)文二進(jìn)制比特寫入 pkgBufpktBuf.Write(buf[:bufLen])for {if pktBuf.Len() <= 0 {break}// 通過(guò) s.reader 將收到的報(bào)文解碼成 RPC 消息pkg, pkgLen, err = s.reader.Read(s, pktBuf.Bytes())// 省略部分代碼s.UpdateActive()// 將收到的消息放入 TaskQueue 供 RPC 消費(fèi)端消費(fèi)s.addTask(pkg)pktBuf.Next(pkgLen)// continue to handle case 5}if exit {break}}return perrors.WithStack(err) }從上面的代碼邏輯我們分析出,RPC 消費(fèi)端需要將從 TCP 連接收到的二進(jìn)制比特報(bào)文解碼成 RPC 能消費(fèi)的消息,這個(gè)工作由 s.reader 實(shí)現(xiàn),所以,我們要構(gòu)建 RPC 通信層也需要實(shí)現(xiàn) s.reader 對(duì)應(yīng)的 Reader 接口。
3. 底層處理網(wǎng)絡(luò)報(bào)文的邏輯如何與業(yè)務(wù)邏輯解耦
我們都知道,netty 通過(guò) boss 線程和 worker 線程實(shí)現(xiàn)了底層網(wǎng)絡(luò)邏輯和業(yè)務(wù)邏輯的解耦。那么,getty 是如何實(shí)現(xiàn)的呢?
在 handlePackage() 方法最后,我們看到,收到的消息被放入了 s.addTask(pkg) 這個(gè)方法,接著往下分析:
func (s *session) addTask(pkg interface{}) {f := func() {s.listener.OnMessage(s, pkg)s.incReadPkgNum()}if taskPool := s.EndPoint().GetTaskPool(); taskPool != nil {taskPool.AddTaskAlways(f)return}f() }pkg 參數(shù)傳遞到了一個(gè)匿名方法,這個(gè)方法最終放入了 taskPool。這個(gè)方法很關(guān)鍵,在我后來(lái)寫 seata-golang 代碼的時(shí)候,就遇到了一個(gè)坑,這個(gè)坑后面分析。
接著我們看一下 taskPool 的定義:
// NewTaskPoolSimple build a simple task pool func NewTaskPoolSimple(size int) GenericTaskPool {if size < 1 {size = runtime.NumCPU() * 100}return &taskPoolSimple{work: make(chan task),sem: make(chan struct{}, size),done: make(chan struct{}),} }構(gòu)建了一個(gè)緩沖大小為 size (默認(rèn)為 ?runtime.NumCPU() * 100) 的 channel sem。再看方法 AddTaskAlways(t task):
func (p *taskPoolSimple) AddTaskAlways(t task) {select {case <-p.done:returndefault:}select {case p.work <- t:returndefault:}select {case p.work <- t:case p.sem <- struct{}{}:p.wg.Add(1)go p.worker(t)default:goSafely(t)} }加入的任務(wù),會(huì)先由 len(p.sem) 個(gè) goroutine 去消費(fèi),如果沒(méi)有 goroutine 空閑,則會(huì)啟動(dòng)一個(gè)臨時(shí)的 goroutine 去運(yùn)行 t()。相當(dāng)于有 ?len(p.sem) 個(gè) goroutine 組成了 goroutine pool,pool 中的 goroutine 去處理業(yè)務(wù)邏輯,而不是由處理網(wǎng)絡(luò)報(bào)文的 goroutine 去運(yùn)行業(yè)務(wù)邏輯,從而實(shí)現(xiàn)了解耦。寫 seata-golang 時(shí)遇到的一個(gè)坑,就是忘記設(shè)置 taskPool 造成了處理業(yè)務(wù)邏輯和處理底層網(wǎng)絡(luò)報(bào)文邏輯的 goroutine 是同一個(gè),我在業(yè)務(wù)邏輯中阻塞等待一個(gè)任務(wù)完成時(shí),阻塞了整個(gè) goroutine,使得阻塞期間收不到任何報(bào)文。
4. 具體實(shí)現(xiàn)
下面的代碼見(jiàn) getty.go:
// Reader is used to unmarshal a complete pkg from buffer type Reader interface {Read(Session, []byte) (interface{}, int, error) }// Writer is used to marshal pkg and write to session type Writer interface {// if @Session is udpGettySession, the second parameter is UDPContext.Write(Session, interface{}) ([]byte, error) }// ReadWriter interface use for handle application packages type ReadWriter interface {ReaderWriter } // EventListener is used to process pkg that received from remote session type EventListener interface {// invoked when session opened// If the return error is not nil, @Session will be closed.OnOpen(Session) error// invoked when session closed.OnClose(Session)// invoked when got error.OnError(Session, error)// invoked periodically, its period can be set by (Session)SetCronPeriodOnCron(Session)// invoked when getty received a package. Pls attention that do not handle long time// logic processing in this func. You'd better set the package's maximum length.// If the message's length is greater than it, u should should return err in// Reader{Read} and getty will close this connection soon.//// If ur logic processing in this func will take a long time, u should start a goroutine// pool(like working thread pool in cpp) to handle the processing asynchronously. Or u// can do the logic processing in other asynchronous way.// !!!In short, ur OnMessage callback func should return asap.//// If this is a udp event listener, the second parameter type is UDPContext.OnMessage(Session, interface{}) }通過(guò)對(duì)整個(gè) getty 代碼的分析,我們只要實(shí)現(xiàn) ?ReadWriter 來(lái)對(duì) RPC ?消息編解碼,再實(shí)現(xiàn) EventListener 來(lái)處理 RPC 消息的對(duì)應(yīng)的具體邏輯,將 ReadWriter 實(shí)現(xiàn)和 EventLister 實(shí)現(xiàn)注入到 RPC 的 Client 和 Server 端,則可實(shí)現(xiàn) RPC 通信。
4.1 編解碼協(xié)議實(shí)現(xiàn)
下面是 seata 協(xié)議的定義:
在 ReadWriter 接口的實(shí)現(xiàn) RpcPackageHandler 中,調(diào)用 Codec 方法對(duì)消息體按照上面的格式編解碼:
// 消息編碼為二進(jìn)制比特 func MessageEncoder(codecType byte, in interface{}) []byte {switch codecType {case SEATA:return SeataEncoder(in)default:log.Errorf("not support codecType, %s", codecType)return nil} }// 二進(jìn)制比特解碼為消息體 func MessageDecoder(codecType byte, in []byte) (interface{}, int) {switch codecType {case SEATA:return SeataDecoder(in)default:log.Errorf("not support codecType, %s", codecType)return nil, 0} }4.2 Client 端實(shí)現(xiàn)
再來(lái)看 client 端 EventListener 的實(shí)現(xiàn) RpcRemotingClient:
func (client *RpcRemoteClient) OnOpen(session getty.Session) error {go func() request := protocal.RegisterTMRequest{AbstractIdentifyRequest: protocal.AbstractIdentifyRequest{ApplicationId: client.conf.ApplicationId,TransactionServiceGroup: client.conf.TransactionServiceGroup,}}// 建立連接后向 Transaction Coordinator 發(fā)起注冊(cè) TransactionManager 的請(qǐng)求_, err := client.sendAsyncRequestWithResponse(session, request, RPC_REQUEST_TIMEOUT)if err == nil {// 將與 Transaction Coordinator 建立的連接保存在連接池供后續(xù)使用clientSessionManager.RegisterGettySession(session)client.GettySessionOnOpenChannel <- session.RemoteAddr()}}()return nil }// OnError ... func (client *RpcRemoteClient) OnError(session getty.Session, err error) {clientSessionManager.ReleaseGettySession(session) }// OnClose ... func (client *RpcRemoteClient) OnClose(session getty.Session) {clientSessionManager.ReleaseGettySession(session) }// OnMessage ... func (client *RpcRemoteClient) OnMessage(session getty.Session, pkg interface{}) {log.Info("received message:{%v}", pkg)rpcMessage, ok := pkg.(protocal.RpcMessage)if ok {heartBeat, isHeartBeat := rpcMessage.Body.(protocal.HeartBeatMessage)if isHeartBeat && heartBeat == protocal.HeartBeatMessagePong {log.Debugf("received PONG from %s", session.RemoteAddr())}}if rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST ||rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST_ONEWAY {log.Debugf("msgId:%s, body:%v", rpcMessage.Id, rpcMessage.Body)// 處理事務(wù)消息,提交 or 回滾client.onMessage(rpcMessage, session.RemoteAddr())} else {resp, loaded := client.futures.Load(rpcMessage.Id)if loaded {response := resp.(*getty2.MessageFuture)response.Response = rpcMessage.Bodyresponse.Done <- trueclient.futures.Delete(rpcMessage.Id)}} }// OnCron ... func (client *RpcRemoteClient) OnCron(session getty.Session) {// 發(fā)送心跳client.defaultSendRequest(session, protocal.HeartBeatMessagePing) }clientSessionManager.RegisterGettySession(session) 的邏輯將在下文中分析。
4.3 Server 端 Transaction Coordinator 實(shí)現(xiàn)
代碼見(jiàn) DefaultCoordinator:
func (coordinator *DefaultCoordinator) OnOpen(session getty.Session) error {log.Infof("got getty_session:%s", session.Stat())return nil }func (coordinator *DefaultCoordinator) OnError(session getty.Session, err error) {// 釋放 TCP 連接SessionManager.ReleaseGettySession(session)session.Close()log.Errorf("getty_session{%s} got error{%v}, will be closed.", session.Stat(), err) }func (coordinator *DefaultCoordinator) OnClose(session getty.Session) {log.Info("getty_session{%s} is closing......", session.Stat()) }func (coordinator *DefaultCoordinator) OnMessage(session getty.Session, pkg interface{}) {log.Debugf("received message:{%v}", pkg)rpcMessage, ok := pkg.(protocal.RpcMessage)if ok {_, isRegTM := rpcMessage.Body.(protocal.RegisterTMRequest)if isRegTM {// 將 TransactionManager 信息和 TCP 連接建立映射關(guān)系coordinator.OnRegTmMessage(rpcMessage, session)return}heartBeat, isHeartBeat := rpcMessage.Body.(protocal.HeartBeatMessage)if isHeartBeat && heartBeat == protocal.HeartBeatMessagePing {coordinator.OnCheckMessage(rpcMessage, session)return}if rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST ||rpcMessage.MessageType == protocal.MSGTYPE_RESQUEST_ONEWAY {log.Debugf("msgId:%s, body:%v", rpcMessage.Id, rpcMessage.Body)_, isRegRM := rpcMessage.Body.(protocal.RegisterRMRequest)if isRegRM {// 將 ResourceManager 信息和 TCP 連接建立映射關(guān)系coordinator.OnRegRmMessage(rpcMessage, session)} else {if SessionManager.IsRegistered(session) {defer func() {if err := recover(); err != nil {log.Errorf("Catch Exception while do RPC, request: %v,err: %w", rpcMessage, err)}}()// 處理事務(wù)消息,全局事務(wù)注冊(cè)、分支事務(wù)注冊(cè)、分支事務(wù)提交、全局事務(wù)回滾等coordinator.OnTrxMessage(rpcMessage, session)} else {session.Close()log.Infof("close a unhandled connection! [%v]", session)}}} else {resp, loaded := coordinator.futures.Load(rpcMessage.Id)if loaded {response := resp.(*getty2.MessageFuture)response.Response = rpcMessage.Bodyresponse.Done <- truecoordinator.futures.Delete(rpcMessage.Id)}}} }func (coordinator *DefaultCoordinator) OnCron(session getty.Session) {}coordinator.OnRegTmMessage(rpcMessage, session) 注冊(cè) Transaction Manager,coordinator.OnRegRmMessage(rpcMessage, session) 注冊(cè) Resource Manager。具體邏輯分析見(jiàn)下文。
消息進(jìn)入 coordinator.OnTrxMessage(rpcMessage, session) 方法,將按照消息的類型碼路由到具體的邏輯當(dāng)中:
switch msg.GetTypeCode() {case protocal.TypeGlobalBegin:req := msg.(protocal.GlobalBeginRequest)resp := coordinator.doGlobalBegin(req, ctx)return respcase protocal.TypeGlobalStatus:req := msg.(protocal.GlobalStatusRequest)resp := coordinator.doGlobalStatus(req, ctx)return respcase protocal.TypeGlobalReport:req := msg.(protocal.GlobalReportRequest)resp := coordinator.doGlobalReport(req, ctx)return respcase protocal.TypeGlobalCommit:req := msg.(protocal.GlobalCommitRequest)resp := coordinator.doGlobalCommit(req, ctx)return respcase protocal.TypeGlobalRollback:req := msg.(protocal.GlobalRollbackRequest)resp := coordinator.doGlobalRollback(req, ctx)return respcase protocal.TypeBranchRegister:req := msg.(protocal.BranchRegisterRequest)resp := coordinator.doBranchRegister(req, ctx)return respcase protocal.TypeBranchStatusReport:req := msg.(protocal.BranchReportRequest)resp := coordinator.doBranchReport(req, ctx)return respdefault:return nil}4.4 session manager 分析
Client 端同 Transaction Coordinator 建立連接起連接后,通過(guò) clientSessionManager.RegisterGettySession(session) 將連接保存在 serverSessions = sync.Map{} 這個(gè) map 中。map 的 key 為從 session 中獲取的 RemoteAddress 即 Transaction Coordinator 的地址,value 為 session。這樣,Client 端就可以通過(guò) map 中的一個(gè) session 來(lái)向 Transaction Coordinator 注冊(cè) Transaction Manager 和 Resource Manager 了。具體代碼見(jiàn) getty_client_session_manager.go。
Transaction Manager 和 Resource Manager 注冊(cè)到 Transaction Coordinator 后,一個(gè)連接既有可能用來(lái)發(fā)送 TM 消息也有可能用來(lái)發(fā)送 RM 消息。我們通過(guò) RpcContext 來(lái)標(biāo)識(shí)一個(gè)連接信息:
type RpcContext struct {Version stringTransactionServiceGroup stringClientRole meta.TransactionRoleApplicationId stringClientId stringResourceSets *model.SetSession getty.Session }當(dāng)收到事務(wù)消息時(shí),我們需要構(gòu)造這樣一個(gè) RpcContext 供后續(xù)事務(wù)處理邏輯使用。所以,我們會(huì)構(gòu)造下列 map 來(lái)緩存映射關(guān)系:
var (// session -> transactionRole// TM will register before RM, if a session is not the TM registered,// it will be the RM registeredsession_transactionroles = sync.Map{}// session -> applicationIdidentified_sessions = sync.Map{}// applicationId -> ip -> port -> sessionclient_sessions = sync.Map{}// applicationId -> resourceIdsclient_resources = sync.Map{} )這樣,Transaction Manager 和 Resource Manager 分別通過(guò) coordinator.OnRegTmMessage(rpcMessage, session) 和 coordinator.OnRegRmMessage(rpcMessage, session) 注冊(cè)到 Transaction Coordinator 時(shí),會(huì)在上述 client_sessions map 中緩存 applicationId、ip、port 與 session 的關(guān)系,在 client_resources map 中緩存 applicationId 與 resourceIds(一個(gè)應(yīng)用可能存在多個(gè) Resource Manager) 的關(guān)系。在需要時(shí),我們就可以通過(guò)上述映射關(guān)系構(gòu)造一個(gè) RpcContext。這部分的實(shí)現(xiàn)和 java 版 seata 有很大的不同,感興趣的可以深入了解一下。具體代碼見(jiàn) getty_session_manager.go。
至此,我們就分析完了 seata-golang 整個(gè) RPC 通信模型的機(jī)制。
三、seata-golang 的未來(lái)
seata-golang ?從今年 4 月份開(kāi)始開(kāi)發(fā),到 8 月份基本實(shí)現(xiàn)和 java 版 seata 1.2 協(xié)議的互通,對(duì) mysql 數(shù)據(jù)庫(kù)實(shí)現(xiàn)了 AT 模式(自動(dòng)協(xié)調(diào)分布式事務(wù)的提交回滾),實(shí)現(xiàn)了 TCC 模式,TC 端使用 mysql 存儲(chǔ)數(shù)據(jù),使 TC 變成一個(gè)無(wú)狀態(tài)應(yīng)用支持高可用部署。下圖展示了 AT 模式的原理:
后續(xù),還有許多工作可以做,比如:對(duì)注冊(cè)中心的支持、對(duì)配置中心的支持、和 java 版 seata 1.4 的協(xié)議互通、其他數(shù)據(jù)庫(kù)的支持、raft transaction coordinator 的實(shí)現(xiàn)等,希望對(duì)分布式事務(wù)問(wèn)題感興趣的開(kāi)發(fā)者可以加入進(jìn)來(lái)一起來(lái)打造一個(gè)完善的 golang 的分布式事務(wù)框架。如果你有任何疑問(wèn),歡迎加入交流群【釘釘群號(hào) 33069364】。
另外,歡迎對(duì) dubbogo 感興趣的朋友到 dubbogo 社區(qū)釘釘群(釘釘群號(hào) 31363295)溝通 dubbogo 技術(shù)問(wèn)題。
參考資料
- seata 官方:https://seata.io
- java 版 seata:https://github.com/seata/seata
- seata-golang 項(xiàng)目地址:https://github.com/opentrx/seata-golang
- seata-golang go 夜讀 b站分享:https://www.bilibili.com/video/BV1oz411e72T
作者簡(jiǎn)介
劉曉敏 (GitHubID dk-lockdown),目前就職于 h3c 成都分公司,擅長(zhǎng)使用 Java/Go 語(yǔ)言,在云原生和微服務(wù)相關(guān)技術(shù)方向均有涉獵,目前專攻分布式事務(wù)。
于雨(github @AlexStocks),dubbo-go 項(xiàng)目和社區(qū)負(fù)責(zé)人,一個(gè)有十多年服務(wù)端基礎(chǔ)架構(gòu)研發(fā)一線工作經(jīng)驗(yàn)的程序員,陸續(xù)參與改進(jìn)過(guò) Muduo/Pika/Dubbo/Sentinel-go 等知名項(xiàng)目,目前在螞蟻金服可信原生部從事容器編排和 service mesh 工作。
總結(jié)
以上是生活随笔為你收集整理的分布式事务框架 seata-golang 通信模型详解的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Serverless 如何落地?揭秘阿里
- 下一篇: 一个改变世界的“箱子”