手把手教你快速理解gRPC!

云加社区

共 16235字,需浏览 33分钟

 · 2021-09-01


导语 | 本文介绍gRPC的基础概念。首先通过关系图直观展示这些基础概念之间关联,介绍异步gRPC的Server和Client的逻辑;然后介绍RPC的类型,阅读和抓包分析gRPC的通信协议,gRPC上下文;最后分析grpc.pb.h文件的内容,包括Stub的能力、Service的种类以及与核心库的关系。之所以谓之基础,是这些内容基本不涉及gRPC Core的内容。



一、基本概念概览




上图中列出了gRPC基础概念及其关系图。其中包括:Service(定义)、RPC、API、Client、Stub、Channel、Server、Service(实现)、ServiceBuilder等。


接下来,以官方提供的example/helloworld为例进行说明。


.proto文件定义了服务Greeter和API SayHello:


// helloworld.proto// The greeting service definition.service Greeter {  // Sends a greeting  rpc SayHello (HelloRequest) returns (HelloReply) {}}


class GreeterClient是Client,是对Stub封装;通过Stub可以真正的调用RPC请求。


class GreeterClient { public:  GreeterClient(std::shared_ptr<Channel> channel)      : stub_(Greeter::NewStub(channel)) {}    std::string SayHello(const std::string& user) {...private:  std::unique_ptr<Greeter::Stub> stub_;};


Channel提供一个与特定gRPC server的主机和端口建立的连接。Stub就是在Channel的基础上创建而成的。


target_str = "localhost:50051";auto channel =    grpc::CreateChannel(target_str, grpc::InsecureChannelCredentials());GreeterClient greeter(channel);std::string user("world");std::string reply = greeter.SayHello(user);     target_str = "localhost:50051";auto channel =    grpc::CreateChannel(target_str, grpc::InsecureChannelCredentials());GreeterClient greeter(channel);std::string user("world");std::string reply = greeter.SayHello(user);


Server端需要实现对应的RPC,所有的RPC组成了Service:


class GreeterServiceImpl final : public Greeter::Service {  Status SayHello(ServerContext* context, const HelloRequest* request,                  HelloReply* reply) override {    std::string prefix("Hello ");    reply->set_message(prefix + request->name());    return Status::OK;  }};


Server的创建需要一个Builder,添加上监听的地址和端口,注册上该端口上绑定的服务,最后构建出Server并启动:


ServerBuilder builder;builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());builder.RegisterService(&service);std::unique_ptr<Server> server(builder.BuildAndStart());


RPC和API的区别:RPC(Remote Procedure Call) 是一次远程过程调用的整个动作,而API(Application Programming Interface) 是不同语言在实现RPC中的具体接口。一个RPC可能对应多种API,比如同步的、异步的、回调的。一次RPC是对某个API的一次调用,比如:


std::unique_ptr<ClientAsyncResponseReader<HelloReply> > rpc(    stub_->PrepareAsyncSayHello(&context, request, &cq));


不管是哪种类型RPC,都是由Client发起请求。



二、异步相关概念


不管是Client还是Server,异步gRPC都是利用CompletionQueue API进行异步操作。

(https://grpc.io/grpc/cpp/classgrpc_1_1_completion_queue.html)


基本的流程:

  • 绑定一个CompletionQueue到一个RPC调用;

  • 利用唯一的void*Tag进行读写;

  • 调用 CompletionQueue::Next()等待操作完成,完成后通过唯一的Tag来判断对应什么请求/返回进行后续操作。


官方文档Asynchronous-API tutorial中有上边的介绍,并介绍了异步client和server的解释,对应着greeter_async_client.cc和greeter_async_server.cc两个文件。

(文档网址:https://grpc.io/docs/languages/cpp/async/)


Client看文档可以理解,但Server的代码复杂,文档和注释中的解释并不是很好理解,接下来会多做一些解释。


(一)异步Client


greeter_async_client.cc中是异步Client的Demo,其中只有一次请求,逻辑简单。


  • 创建CompletionQueue;


  • 创建RPC

    (ClientAsyncResponseReader<HelloReply>)

    有两种方式:

  • stub_->PrepareAsyncSayHello() + rpc->StartCall();

  • stub_->AsyncSayHello()。


  • 调用rpc->Finish()设置请求消息reply和唯一的tag关联,将请求发送出去;

  • 使用cq.Next()等待Completion Queue返回响应消息体,通过tag关联对应的请求。


[TODO]ClientAsyncResponseReader在Finish()后就没有用了?



(二)异步Server


RequestSayHello()这个函数没有任何的说明。只说是:"we request that the system start processing SayHello requests." 也没有说跟cq_->Next(&tag, &ok);的关系。我这里通过加上一些日志打印,来更清晰的展示Server的逻辑:



上边绿色的部分为创建的第一个CallData对象地址,橙色的为第二个CallData的地址。


  • 创建一个CallData,初始构造列表中将状态设置为CREATE;

  • 构造函数中,调用Process()成员函数,调用service_->RequestSayHello()后,状态变更为PROCESS:


  • 传入ServerContext ctx_;

  • 传入HelloRequest request_;

  • 传入ServerAsyncResponseWriter<HelloReply>responder-_;

  • 传入ServerCompletionQueue*cq_;

  • 将对象自身的地址作为tag传入;

  • 该动作,能将事件加入事件循环,可以在CompletionQueue中等待。

  • 收到请求,cq->Next()的阻塞结束并返回,得到tag,既上次传入的CallData对象地址;

  • 调用tag对应CallData对象的Proceed(),此时状态为Process;


  • 创建新的CallData对象以接收新请求;

  • 处理消息体并设置reply;

  • 将状态设置为FINISH;

  • 用responder_.Finish()将返回发送给客户端;

  • 该动作,能将事件加入到事件循环,可以在CompletionQueue中等待。

  • 发送完毕,cq->Next()的阻塞结束并返回,得到tag。现实中,如果发送有异常应当有其他相关的处理;

  • 调用tag对应CallData对象的Proceed(),此时状态为FINISH,delete this清理自己,一条消息处理完成。



(三)关系图


将上边的异步Client和异步Server的逻辑通过关系图进行展示。右侧RPC为创建的对象中的内存容,左侧使用相同颜色的小块进行代替。



以下CallData并非gRPC中的概念,而是异步Server在实现过程中为了方便进行的封装,其中的Status也是在异步调用过程中自定义的、用于转移的状态。




(四)异步Client2


在example/cpp/helloworld中还有另外一个异步Client,对应文件名为greeter_async_client2.cc。这个例子中使用了两个线程去分别进行发送请求和处理返回,一个线程批量发出100个SayHello的请求,另外一个不断的通过cq_.Next()来等待返回。


无论是Client还是Server,在以异步方式进行处理时,都要预先分配好一定的内存/对象,以存储异步的请求或返回。



(五)回调方式的异步调用


在example/cpp/helloworld中,还提供了callback相关的Client和Server。


使用回调方式简介明了,结构上与同步方式相差不多,但是并发有本质的区别。可以通过文件对比,来查看其中的差异。


cd examples/cpp/helloworld/vimdiff greeter_callback_client.cc greeter_client.ccvimdiff greeter_callback_server.cc greeter_server.cc


其实,回调方式的异步调用属于实验性质的,不建议直接在生产环境使用,这里也只做简单的介绍:


注意: This API is EXPERIMENTAL and may be changed or removed at any time.


  • 回调Client


发送单个请求,在调用SayHello时,除了传入Request、Reply的地址之外,还需要传入一个接收Status的回调函数。


例子中只有一个请求,因此在SayHello之后,就直接通过condition_variable的wait函数等待回调结束,然后进行后续处理。这样其实不能进行并发,跟同步请求差别不大。如果要进行大规模的并发,还是需要使用额外的对象进行封装一下。


stub_->async()->SayHello(&context, &request, &reply,                         [&mu, &cv, &done, &status](Status s) {                           status = std::move(s);                           std::lock_guard<std::mutex> lock(mu);                           done = true;                           cv.notify_one();                         });


上边函数调用函数声明如下,很明显这是实验性(experimental)的接口:


void Greeter::Stub::experimental_async::SayHello(    ::grpc::ClientContext* context, const ::helloworld::HelloRequest* request,    ::helloworld::HelloReply* response, std::function<void(::grpc::Status)> f);



  • 回调Server


与同步Server不同的是:

  • 服务的实现是继承Greeter::CallbackService;

  • SayHello返回的不是状态,而是ServerUnaryReactor指针;

  • 通过CallbackServerContext获得reactor;

  • 调用reactor的Finish函数处理返回状态。




三、流相关概念


可以按照Client和Server一次发送/返回的是单个消息还是多个消息,将gRPC分为:

  • Unary RPC;

  • Server streaming RPC;

  • Client streaming RPC;

  • Bidirectional streaming RPC。


(一)Server对RPC的实现


Server需要实现proto中定义的RPC,每种RPC的实现都需要将ServerContext作为参数输入。


如果是一元(Unary)RPC调用,则像调用普通函数一样。将Request和Reply的对象地址作为参数传入,函数中将根据Request的内容,在Reply的地址上写上对应的返回内容。


// rpc GetFeature(Point) returns (Feature) {}Status GetFeature(ServerContext* context, const Point* point, Feature* feature);


如果涉及到流,则会用Reader或/和Writer作为参数,读取流内容。如ServerStream模式下,只有Server端产生流,这时对应的Server返回内容,需要使用作为参数传入的ServerWriter。这类似于以'w'打开一个文件,持续的往里写内容,直到没有内容可写关闭。


// rpc ListFeatures(Rectangle) returns (stream Feature) {}Status ListFeatures(ServerContext* context,                    const routeguide::Rectangle* rectangle,                    ServerWriter<Feature>* writer);


另一方面,Client来的流,Server需要使用一个ServerReader来接收。这类似于打开一个文件,读其中的内容,直到读到EOF为止类似。


// rpc RecordRoute(stream Point) returns (RouteSummary) {}Status RecordRoute(ServerContext* context, ServerReader<Point>* reader,                   RouteSummary* summary);


如果Client和Server都使用流,也就是Bidirectional-Stream模式下,输入参数除了ServerContext之外,只有一个ServerReaderWriter指针。通过该指针,既能读Client来的流,又能写Server产生的流。


例子中,Server不断地从stream中读,读到了就将对应的写过写到stream中,直到客户端告知结束;Server处理完所有数据之后,直接返回状态码即可。


// rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}Status RouteChat(ServerContext* context,                 ServerReaderWriter<RouteNote, RouteNote>* stream);



(二)Client对RPC的调用


Client在调用一元(Unary)RPC时,像调用普通函数一样,除了传入ClientContext之外,将Request和Response的地址,返回的是RPC状态:


// rpc GetFeature(Point) returns (Feature) {}Status GetFeature(ClientContext* context, const Point& request,                  Feature* response);


Client在调用ServerStream RPC时,不会得到状态,而是返回一个ClientReader的指针:


// rpc ListFeatures(Rectangle) returns (stream Feature) {}unique_ptr<ClientReader<Feature>> ListFeatures(ClientContext* context,                                               const Rectangle& request);


Reader通过不断的Read(),来不断的读取流,结束时Read()会返回false;通过调用Finish()来读取返回状态。


调用ClientStream RPC时,则会返回一个ClientWriter指针:


// rpc RecordRoute(stream Point) returns (RouteSummary) {}unique_ptr<ClientWriter<Point>> RecordRoute(ClientContext* context,                                            Route Summary* response);


Writer会不断的调用Write()函数将流中的消息发出;发送完成后调用WriteDone()来说明发送完毕;调用Finish()来等待对端发送状态。


而双向流的RPC时,会返回ClientReaderWriter:


// rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}unique_ptr<ClientReaderWriter<RouteNote, RouteNote>> RouteChat(    ClientContext* context);


前面说明了Reader和Writer读取和发送完成的函数调用。因为RPC都是Client请求而后Server响应,双向流也是要Client先发送完自己流,才有Server才可能结束 RPC。所以对于双向流的结束过程是:

  • stream->WriteDone();

  • stream->Finish()。

示例中创建了单独的一个线程去发送请求流,在主线程中读返回流,实现了一定程度上的并发。



(三)流是会结束的


并不似长连接,建立上之后就一直保持,有消息的时候发送。(是否有通过建立一个流RPC建立推送机制?)


  • Client发送流,是通过Writer->WritesDone()函数结束流;

  • Server发送流,是通过结束RPC函数并返回状态码的方式来结束流;


  • 流接受者,都是通过Reader->Read()返回的bool型状态,来判断流是否结束。

Server并没有像Client一样调用WriteDone(),而是在消息之后,将 status code、可选的status message、可选的trailing metadata追加进行发送,这就意味着流结束了。



四、通信协议


本节通过介绍gRPC协议文档描述和对helloworld的抓包,来说明gRPC到底是如何传输的。


官方文档《gRPC over HTTP2》中有描述gRPC基于HTTP2的具体实现,主要介绍的就是协议,也就是gRPC的请求和返回是如何基于HTTP协议构造的。如果不熟悉HTTP2可以阅读一下RFC 7540

(https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)


(一)ABNF


ABNF语法是一种描述协议的标准,gRPC协议也是使用ABNF语法描述,几种常见的运算符在第三节中有介绍:


3.  Operators 3.1.  Concatenation:  Rule1 Rule2 3.2.  Alternatives:  Rule1 / Rule2 3.3.  Incremental Alternatives: Rule1 =/ Rule2 3.4.  Value Range Alternatives:  %c##-## 3.5.  Sequence Group:  (Rule1 Rule2) 3.6.  Variable Repetition:  *Rule 3.7.  Specific Repetition:  nRule 3.8.  Optional Sequence:  [RULE] 3.9.  Comment:  ; Comment 3.10. Operator Precedence



(二)请求协议


*<element>表示element会重复多次(最少0次)。知道这个就能理解概况里的描述了:


Request → Request-Headers *Length-Prefixed-Message EOSRequest-Headers → Call-Definition *Custom-Metadata


  • Request-Headers


这表示Request是由3部分组成,首先是Request-Headers,接下来是可能多次出现的Length-Prefixed-Message,最后以一个EOS结尾(EOS表示End-Of-Stream)。


根据上边的协议描述,Request-Headers是由一个Call-Definition和若干Custom-Metadata组成。


[]表示最多出现一次,比如Call-Definition有很多组成部分,其中Message-Type等是选填的:


Call-Definition → Method Scheme Path TE [Authority] [Timeout] Content-Type [Message-Type] [Message-Encoding] [Message-Accept-Encoding] [User-Agent]


通过Wireshark抓包可以看到请求的Call-Definition中共有所有要求的 Header,还有额外可选的,比如user-agent:



因为helloworld的示例比较简单,请求中没有填写自定义的元数据(Custom-Metadata)。



  • 传输的Length-Prefixed-Message


传输的Length-Prefixed-Message也分为三部分:


Length-Prefixed-Message → Compressed-Flag Message-Length Message


同样的,Wireshark抓到的请求中也有这部分信息,并且设置.proto文件的搜索路径之后可以自动解析PB:



其中第一个红框(Compressed-Flag)表示不进行压缩,第二个红框(Message-Length)表示消息长度为7,蓝色反选部分则是Protobuf 序列化的二进制内容,也就是Message。


在gRPC的核心概念介绍时提到,gRPC默认使用Protobuf作为接口定义语言(IDL),也可以使用其他的IDL替代Protobuf:


By default, gRPC uses protocol buffers as the Interface Definition Language (IDL) for describing both the service interface and the structure of the payload messages. It is possible to use other alternatives if desired.


这里Length-Prefixed-Message中传输的可以是PB也可以是JSON,须通过Content-Type头中描述告知。



  • EOS

End-Of-Stream并没有单独的数据去描述,而是通过HTTP2的数据帧上带一个END_STREAM的flag来标识的。比如helloworld中请求的数据帧,也携带了END_STREAM的标签:




(三)返回协议


()表示括号中的内容作为单个元素对待,/表示前后两个元素可选其一。Response的定义说明,可以有两种返回形式,一种是消息头、消息体、Trailer,另外一种是只带Trailer:


Response → (Response-Headers *Length-Prefixed-Message Trailers) / Trailers-Only


这里需要区分gRPC的Status和HTTP的Status两种状态。


Response-HeadersHTTP-Status [Message-Encoding] [Message-Accept-Encoding] Content-Type *Custom-MetadataTrailers-OnlyHTTP-Status Content-Type TrailersTrailersStatus [Status-Message] *Custom-Metadata

不管是哪种形式,最后一部分都是Trailers,其中包含了gRPC的状态码、状态信息和额外的自定义元数据。


同样地,使用END_STREAM的flag标识最后Trailer的结束。




(四)与HTTP/2的关系


The libraries in this repository provide a concrete implemnetation of the gRPC protocol,layered over HTTP/2.





五、上下文


gRPC支持上下文的传递,其主要用途有:

  • 添加自定义的metadata,能够通过gRPC调用传递;

  • 控制调用配置,如压缩、鉴权、超时;

  • 从对端获取metadata;

  • 用于性能测量,比如使用opencensus等。


客户端添加自定义的metadata key-value对没有特别的区分,而服务端添加的,则有inital和trailing两种metadata的区分。这也分别对应这ClientContext只有一个添加Metadata的函数:


void AddMetadata (const std::string &meta_key, const std::string &meta_value)


而ServerContext则有两个:


void AddInitialMetadata (const std::string &key, const std::string &value)void AddTrailingMetadata (const std::string &key, const std::string &value)


还有一种Callback Server对应的上下文叫做CallbackServerContext,它与ServerContext继承自同一个基类,功能基本上相同。区别在于:


  • ServerContext被Sync Server 和基于CQ的Async Server所使用,后者需要用到AsyncNotifyWhenDone;


  • CallbackServerContext因为在CallOnDone的时候,需要释放context,因此需要知道context_allocator,因此对应设置和获取context_allocator的两个函数。




六、Generated Code


通过protoc生成gRPC相关的文件,除了用于消息体定义的xxx.pb.h和xxx.pb.cc文件之外,就是定义RPC过程xxx.grpc.pb.h和xxx.grpc.pb.cc。本节以helloworld.proto生成的文件为例,看看.grpc.pb相关文件具体定义了些什么。


helloworld.grpc.pb.h文件中有命名空间helloworld,其中就仅包含一个类Greeter,所有的RPC相关定义都在Greeter当中,这其中又主要分为两部分:


  • Client 用于调用 RPC 的媒介Stub相关类;

  • Server 端用于实现不同服务的Service相关类和类模板。


(一)Stub


.proto中的一个service只有一个Stub,该类中会提供对应每个RPC所有的同步、异步、回调等方式的函数都包含在该类中,而该类继承自接口类StubInterface。


为什么需要一个StubInterface来让Stub继承,而不是直接产生Stub?别的复杂的proto会有多个Stub继承同一个StubInterface的情况?不会,因为每个RPC对应的函数名是不同。


Greeter中唯一一个函数是用于创建Stub的静态函数NewStub:


static std::unique_ptr<Stub> NewStub(...)


Stub中同步、异步方式的函数是直接作为Stub的成员函数提供,比如针对一元调用:

  • SayHello;

  • AsyncSayHello;

  • PrepareAsyncSayHello。

[TODO] 为什么同步函数SayHello的实现是放在源代码中,而异步函数AsyncSayHello的实现是放在头文件中(两者都是直接return的)?


return ::grpc::internal::BlockingUnaryCall< ::helloworld::HelloRequest, ::helloworld::HelloReply, ::grpc::protobuf::MessageLite, ::grpc::protobuf::MessageLite>(channel_.get(), rpcmethod_SayHello_, context, request, response);


return std::unique_ptr< ::grpc::ClientAsyncResponseReader< ::helloworld::HelloReply>>(AsyncSayHelloRaw(context, request, cq));


回调方式的RPC调用是通过一个experimental_async的类进行了封装(有个async_stub_的成员变量),所以回调Client中提到,回调的调用方式用法是stub_->async()->SayHello(...)。

experimental_async类定义中将Stub类作为自己的友元,自己的成员可以被Stub直接访问,而在StubInterface中也对应有一个experimental_async_interface的接口类,规定了要实现哪些接口。



(二)Service


有几个概念都叫Service:proto文件中RPC的集合、proto文件中service产生源文件中的Greeter::Service类、gRPC框架中的::grpc::Service类。本小节说的Service就是helloworld.grpc.pb.h中的Greeter::Service。


  • Service是如何定义的

helloworld.grpc.pb.h文件中共定义了7种Service,拿出最常用的Service和AsyncService两个定义来说明下Service的定义过程:通过类模板链式继承。


Service跟其他几种Service不同,直接继承自grpc::Service,而其他的Service都是由类模板构造出来的,而且使用类模板进行嵌套,最基础的类就是这里的Service


Service有以下特点:

  • 构造函数利用其父类grpc::Service的AddMethod()函数,将.proto文件中定义的RPC API,添加到成员变量methods_中(methods_是个向量);

  • AddMethod() 时会创建 RpcServiceMethod 对象,而该对象有一个属性叫做 api_type_,构造时默认填的 ApiType::SYNC;

  • SayHello函数不直接声明为纯虚函数,而是以返回UNIMPLEMENTED状态,因为这个类可能被多次、多级继承。


所以Service类中的所有RPC API都是同步的


再看AsyncService的具体定义:


template <class BaseClass>  class WithAsyncMethod_SayHello : public BaseClass { ... };
typedef WithAsyncMethod_SayHello<Service > AsyncService;


所以AsyncService的含义就是继承自Service,然后再加上了WithAsyncMethod_SayHello的新功能:

  • 构造时,将SayHello(RPC)对应的api_type_设置为ApiType::ASYNC;

  • 将SayHello函数直接禁用掉,abort()+返回UNIMPLEMENTED状态码;

  • 添加RequestSayHello()函数,[异步 Server](#2-异步 Server)小节中有介绍过这个函数用法。

通过gRPC提供的route_guide.proto例子能更明显的理解这点:


typedef WithAsyncMethod_GetFeature< \    WithAsyncMethod_ListFeatures< \    WithAsyncMethod_RecordRoute< \    WithAsyncMethod_RouteChat<Service> > > >     AsyncService;


这里RouteGuide服务中有4个RPC,GetFeature、ListFeatures、RecordRoute和RouteChat,通过4个WithAsyncMethod_{RPC_name}的类模板嵌套,能将4API都设置成ApiType::ASYNC、添加上对应的RequestXXX()函数、禁用同步函数。


[TODO]通过类模板嵌套继承的方式,有什么好处?为什么不直接实现AsyncService这个类呢?



  • Service的种类

helloworld.grpc.pb.h文件中7种Service中,有3对Service的真正含义都相同(出于什么目的使用不同的名称?),实际只剩下4种Service。前三种在前边的同步、[异步]Server的介绍中都有涉及。

  • Service;

  • AsyncService;

  • CallbackService;

  • ExperimentalCallbackService——等价CallbackService;

  • StreamedUnaryService;

  • SplitStreamedService——等价Service;

  • StreamedService——等价StreamedUnaryService;

其实这些不同类型的Service是跟前边提到的api_type_有关。使用不同的::grpc::Service::MarkMethodXXX设置不同的ApiType会产生不同的API模板类,所有API模板类级联起来,就得到了不同的Service。这三者的关系简单列举如下:



另外还有两种模板是通过设置其他属性产生的,这里暂时不做介绍:



[TODO]头文件中没有用到的类模板在什么场景中会用到?



(三)与::grpc核心库的关系


Stub类中主要是用到gRPC Channel和不同类型RPC对应的方法实现:



Service类则继承自::grpc::Service,具备其父类的能力,需要自己实现一些RPC方法具体的处理逻辑。其它Service涉及到gRPC核心库的联系有:

  • AsyncService::RequestSayHello()调用::grpc::Service::RequestAsyncUnary;

  • CallbackService::SayHello()函数返回的是::grpc::ServerUnaryReactor指针;

  • CallbackService::SetMessageAllocatorFor_SayHello()函数中调用::grpc::internal::CallbackUnaryHandler::SetMessageAllocator()函数设置RPC方法的回调的消息分配器。


[TODO]SetMessageAllocatorFor_SayHello()函数并没有被调用到,默认该分配器指针初始值为空,表示用户预先自己分配好而无需回调时分配?


参考资料
1. 核心概念、架构和生命周期

2. 使用线沙克分析gRPC消息

3. grpc超过Http2



 作者简介


潘忠显

腾讯运营开发工程师

腾讯运营开发工程师,毕业于西安电子科技大学,长期从事运营系统开发,喜欢排查各类问题,对后台框架有一定的了解。



 推荐阅读


golang:快来抓住让我内存泄漏的“真凶”!

一文读懂@Decorator装饰器——理解VS Code源码的基础(下)

一文读懂@Decorator装饰器——理解VS Code源码的基础(上)

go语言最全优化技巧总结,值得收藏!





浏览 30
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报