简介

gRPC 是一个高性能、开源、通用的RPC框架,由Google推出,基于HTTP/2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言。gRPC提供了一种简单的方法来精确的定义服务,并且为客户端和服务端自动生成可靠的功能库。

gRPC is a modern open source high performance RPC framework that can run in any environment. It can efficiently connect services in and across data centers with pluggable support for load balancing, tracing, health checking and authentication. It is also applicable in last mile of distributed computing to connect devices, mobile applications and browsers to backend services.

The main usage scenarios:

  • Efficiently connecting polyglot services in microservices style architecture
  • Connecting mobile devices, browser clients to backend services
  • Generating efficient client libraries

Core Features that make it awesome:

  • Idiomatic client libraries in 10 languages
  • Highly efficient on wire and with a simple service definition framework
  • Bi-directional streaming with http/2 based transport
  • Pluggable auth, tracing, load balancing and health checking

安装

Go 版本

gRPC 需要 Go 1.6 或者更高的版本

# 查看 Go 版本
$ go version

安装 gRPC

$ go get -u google.golang.org/grpc

安装 Protocol Buffers v3

需要安装 proto 编译器去生成 gRPC 服务代码,可以去 https://github.com/google/protobuf/releases 下载指定版本(protoc-<version>-<platform>.zip)的二进制文件。

  • 解压文件
  • 把文件夹中的 protoc 放在 $PTAH 目录下
  • 把文件夹中的 include 移动到 /usr/local/include

复制 include 是为了使用一些新的类型

安装 protoc 的 Go 插件

# $ go get -u github.com/golang/protobuf/protoc-gen-go
$ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}

编译器插件 protoc-gen-go 将会安装在 $GOBIN 目录下,一定要在 $PATH 下,protoc 可以直接调用。

$ export PATH=$PATH:$GOPATH/bin

示例

gRPC 使用流程

  1. 编写 .proto 文件,指定参数、返回值和方法
  2. 使用 protoc 等工具生成 .pb.go 文件
  3. 根据 .proto 中的约定编写服务端逻辑
  4. 根据 .proto 中的约定编写客户端逻辑

grpc 的代码中已经包含了示例,可以在 $GOPATH/src/google.golang.org/grpc/examples 目录中找到,或者可以去 https://github.com/grpc/grpc-go/tree/master/examples 查看。

helloworld 为例

.
├── greeter_client # gRPC 客户端
│   └── main.go
├── greeter_server # gRPC 服务端
│   └── main.go
├── helloworld # proto 文件,为了方便,.pb.go 为 protoc 生成的文件
│   ├── helloworld.pb.go
│   └── helloworld.proto
└── mock_helloworld # mock 数据
    ├── hw_mock.go
    └── hw_mock_test.go

4 directories, 6 files

服务端代码

package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

const (
    port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct{}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v", in.Name)
    return &pb.HelloReply{Message: "Hello " + in.Name}, nil
}

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

客户端代码

package main

import (
    "context"
    "log"
    "os"
    "time"

    "google.golang.org/grpc"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

const (
    address     = "localhost:50051"
    defaultName = "world"
)

func main() {
    // Set up a connection to the server.
    conn, err := grpc.Dial(address, grpc.WithInsecure())
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewGreeterClient(conn)

    // Contact the server and print out its response.
    name := defaultName
    if len(os.Args) > 1 {
        name = os.Args[1]
    }
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()
    r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
    if err != nil {
        log.Fatalf("could not greet: %v", err)
    }
    log.Printf("Greeting: %s", r.Message)
}

proto 文件

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";

package helloworld;

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

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

helloworld.pb.goprotoc 通过 helloworld.proto 编译生成的,其中包含了

  • 生成的客户端服务端代码
  • 用于填充,序列化和检索 HelloRequest 和 HelloReply 消息类型的代码

运行服务端和客户端代码

$ go run greeter_server/main.go

在另一个终端

$ go run greeter_client/main.go

如果一切正常,将会在客户端的输出看到 Greeting: Hello world

proroc 的使用

使用 protoc 工具编译 .proto 文件,不同的语言需要使用不同的输出参数

其中一些主要的参数

  • -I 参数:指定import路径,可以指定多个-I参数,编译时按顺序查找,不指定时默认查找当前目录
  • --go_out参数 :golang编译支持,支持以下参数
    • plugins=plugin1+plugin2 - 指定插件,目前只支持grpc,即:plugins=grpc
    • M 参数 - 指定导入的.proto文件路径编译后对应的golang包名(不指定本参数默认就是.proto文件中import语句的路径)
    • import_prefix=xxx - 为所有import路径添加前缀,主要用于编译子目录内的多个proto文件,这个参数按理说很有用,尤其适用替代一些情况时的M参数,但是实际使用时有个蛋疼的问题导致并不能达到我们预想的效果,自己尝试看看吧
    • import_path=foo/bar - 用于指定未声明package或go_package的文件的包名,最右面的斜线前的字符会被忽略
    • 末尾 :编译文件路径 .proto文件路径(支持通配符)

示例:

$ protoc -I/usr/local/include -I. \
        -I$GOPATH/src \
        -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
        --go_out=plugins=grpc:. \
        dns_pb/*.proto

Protocol Buffers 语法

推荐直接学习 proto3

gRPC 认证

gRPC 默认提供了两种认证方式

  • 基于 SSL/TLS 认证方式
  • 远程调用认证方式

TLS 认证示例

helloworld 为例

准备证书

准备私钥

# Key considerations for algorithm "RSA" ≥ 2048-bit
$ openssl genrsa -out server.key 2048
    
# Key considerations for algorithm "ECDSA" ≥ secp384r1
# List ECDSA the supported curves (openssl ecparam -list_curves)
$ openssl ecparam -genkey -name secp384r1 -out server.key

准备公钥

$ openssl req -new -x509 -sha256 -key server.key -out server.pem -days 3650

---

Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:XxXx
Locality Name (eg, city) []:XxXx
Organization Name (eg, company) [Internet Widgits Pty Ltd]:XX Co. Ltd
Organizational Unit Name (eg, section) []:Dev
Common Name (e.g. server FQDN or YOUR name) []:server name # 客户端需要使用该字段
Email Address []:xxx@xxx.com

公钥需要填写一些自定义信息,注意其中的 Common Name,在后面的客户端连接中需要使用

目录结构

.
├── greeter_client
│   └── main.go
├── greeter_server
│   └── main.go
├── helloworld
│   ├── helloworld.pb.go
│   └── helloworld.proto
├── keys
│   ├── server.key
│   └── server.pem
└── mock_helloworld
    ├── hw_mock.go
    └── hw_mock_test.go

5 directories, 8 files

修改代码

修改 helloworld 服务端代码

@@ -27,7 +27,9 @@ import (
    "google.golang.org/grpc"
+   "google.golang.org/grpc/credentials"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
+   "google.golang.org/grpc/grpclog"
 )
 
 const (
@@ -48,7 +50,16 @@ func main() {
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
-   s := grpc.NewServer()
+
+   // TLS认证
+   creds, err := credentials.NewServerTLSFromFile("../keys/server.pem", "../keys/server.key")
+   if err != nil {
+       grpclog.Fatalf("Failed to generate credentials %v", err)
+   }
+
+   // 实例化grpc Server, 并开启TLS认证
+   s := grpc.NewServer(grpc.Creds(creds))
+
    pb.RegisterGreeterServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)

修改修改 helloworld 客户端代码

@@ -26,7 +26,9 @@ import (
    "time"
 
    "google.golang.org/grpc"
+   "google.golang.org/grpc/credentials"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
+   "google.golang.org/grpc/grpclog"
 )
 
 const (
@@ -35,8 +37,13 @@ const (
 )
 
 func main() {
+   // TLS连接
+   creds, err := credentials.NewClientTLSFromFile("../keys/server.pem", "demo")
+   if err != nil {
+       grpclog.Fatalf("Failed to create TLS credentials %v", err)
+   }
    // Set up a connection to the server.
-   conn, err := grpc.Dial(address, grpc.WithInsecure())
+   conn, err := grpc.Dial(address, grpc.WithTransportCredentials(creds))
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }

客户端添加TLS认证的方式和服务端类似,在创建连接Dial时,同样可以配置多种选项,后面的示例中会看到更多的选项。

基于 Token 认证

一种方案是手动编写,在服务端的每个方法里增加判断方法,或者使用 gRPC 拦截器,进行统一处理

在上面 TLS 基础上进行更改

手动编写

修改服务端代码

@@ -27,9 +27,11 @@ import (
    "net"
 
    "google.golang.org/grpc"
+   "google.golang.org/grpc/codes"
    "google.golang.org/grpc/credentials"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
    "google.golang.org/grpc/grpclog"
+   "google.golang.org/grpc/metadata"
 )
 
 const (
@@ -41,6 +43,29 @@ type server struct{}
 
 // SayHello implements helloworld.GreeterServer
 func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
+   // 解析metada中的信息并验证
+   md, ok := metadata.FromIncomingContext(ctx)
+   if !ok {
+       return nil, grpc.Errorf(codes.Unauthenticated, "无Token认证信息")
+   }
+
+   var (
+       appid  string
+       appkey string
+   )
+
+   if val, ok := md["appid"]; ok {
+       appid = val[0]
+   }
+
+   if val, ok := md["appkey"]; ok {
+       appkey = val[0]
+   }
+
+   if appid != "101010" || appkey != "i am key" {
+       return nil, grpc.Errorf(codes.Unauthenticated, "Token认证信息无效: appid=%s, appkey=%s", appid, appkey)
+   }
+
    log.Printf("Received: %v", in.Name)
    return &pb.HelloReply{Message: "Hello " + in.Name}, nil
 }

修改客户端

const (
    address     = "localhost:50051"
    defaultName = "world"
+
+   // OpenTLS 是否开启TLS认证
+   OpenTLS = true
 )
 
+// customCredential 自定义认证
+// 需要实现 credentials.PerRPCCredentials 接口
+type customCredential struct{}
+
+func (c customCredential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
+   return map[string]string{
+       "appid":  "101010",
+       "appkey": "i am key",
+   }, nil
+}
+
+func (c customCredential) RequireTransportSecurity() bool {
+   if OpenTLS {
+       return true
+   }
+
+   return false
+}
+
 func main() {
    // TLS连接
    creds, err := credentials.NewClientTLSFromFile("../keys/server.pem", "demo")
    if err != nil {
        grpclog.Fatalf("Failed to create TLS credentials %v", err)
    }
+
    // Set up a connection to the server.
-   conn, err := grpc.Dial(address, grpc.WithTransportCredentials(creds))
+   conn, err := grpc.Dial(address,
+       grpc.WithTransportCredentials(creds),
+       grpc.WithPerRPCCredentials(new(customCredential)),
+   )
+
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }

这里我们定义了一个 customCredential 结构,并实现了两个方法 GetRequestMetadataRequireTransportSecurity。这是 gRPC提供的自定义认证方式,每次RPC调用都会传输认证信息。customCredential其实是实现了 grpc/credential 包内的PerRPCCredentials接口。每次调用,token信息会通过请求的metadata传输到服务端。

基于拦截器

grpc服务端提供了interceptor功能,可以在服务端接收到请求时优先对请求中的数据做一些处理后再转交给指定的服务处理并响应,功能类似middleware,很适合在这里处理验证、日志等流程。

修改服务端

@@ -27,9 +27,11 @@ import (
    "net"
 
    "google.golang.org/grpc"
+   "google.golang.org/grpc/codes"
    "google.golang.org/grpc/credentials"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
    "google.golang.org/grpc/grpclog"
+   "google.golang.org/grpc/metadata"
 )
 
 const (
@@ -45,6 +47,33 @@ func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloRe
    return &pb.HelloReply{Message: "Hello " + in.Name}, nil
 }
 
+// auth 验证Token
+func auth(ctx context.Context) error {
+   md, ok := metadata.FromIncomingContext(ctx)
+   if !ok {
+       return grpc.Errorf(codes.Unauthenticated, "无Token认证信息")
+   }
+
+   var (
+       appid  string
+       appkey string
+   )
+
+   if val, ok := md["appid"]; ok {
+       appid = val[0]
+   }
+
+   if val, ok := md["appkey"]; ok {
+       appkey = val[0]
+   }
+
+   if appid != "101010" || appkey != "i am key" {
+       return grpc.Errorf(codes.Unauthenticated, "Token认证信息无效: appid=%s, appkey=%s", appid, appkey)
+   }
+
+   return nil
+}
+
 func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
@@ -57,8 +86,19 @@ func main() {
        grpclog.Fatalf("Failed to generate credentials %v", err)
    }
 
+   // 注册interceptor
+   var interceptor grpc.UnaryServerInterceptor
+   interceptor = func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
+       err = auth(ctx)
+       if err != nil {
+           return
+       }
+       // 继续处理请求
+       return handler(ctx, req)
+   }
+
    // 实例化grpc Server, 并开启TLS认证,添加拦截器
-   s := grpc.NewServer(grpc.Creds(creds))
+   s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(interceptor))
 
    pb.RegisterGreeterServer(s, &server{})
    if err := s.Serve(lis); err != nil {

grpc-ecosystem

gRPC Ecosystem that complements gRPC

grpc-ecosystem

推荐其中的两个项目 go-grpc-middlewaregrpc-gateway

go-grpc-middleware

基于 gRPC 的拦截器实现一些中间件功能,例如:日志,revocery,auth等

import "github.com/grpc-ecosystem/go-grpc-middleware"

myServer := grpc.NewServer(
    grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
        grpc_ctxtags.StreamServerInterceptor(),
        grpc_opentracing.StreamServerInterceptor(),
        grpc_prometheus.StreamServerInterceptor,
        grpc_zap.StreamServerInterceptor(zapLogger),
        grpc_auth.StreamServerInterceptor(myAuthFunction),
        grpc_recovery.StreamServerInterceptor(),
    )),
    grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
        grpc_ctxtags.UnaryServerInterceptor(),
        grpc_opentracing.UnaryServerInterceptor(),
        grpc_prometheus.UnaryServerInterceptor,
        grpc_zap.UnaryServerInterceptor(zapLogger),
        grpc_auth.UnaryServerInterceptor(myAuthFunction),
        grpc_recovery.UnaryServerInterceptor(),
    )),
)

推荐其中的 grpc_recovery,可以处理服务端的 panic

grpc-gateway

强烈推荐

使用场景:etcd3改用grpc后为了兼容原来的api,同时要提供http/json方式的API,为了满足这个需求,要么开发两套API,要么实现一种转换机制,他们选择了后者。

编写一套 gRPC 服务,然后启动一个gateway,gatew处理 http 请求,gRPC 服务处理 gRPC 请求。

原理:gateway 充当一个 gRPC 客户端,gateway 接收 http 请求,转化成 gRPC 请求,发送到 gRPC 服务端,相应再原路返回

gRPC is great – it generates API clients and server stubs in many programming languages, it is fast, easy-to-use, bandwidth-efficient and its design is combat-proven by Google. However, you might still want to provide a traditional RESTful API as well. Reasons can range from maintaining backwards-compatibility, supporting languages or clients not well supported by gRPC to simply maintaining the aesthetics and tooling involved with a RESTful architecture.

This project aims to provide that HTTP+JSON interface to your gRPC service. A small amount of configuration in your service to attach HTTP semantics is all that’s needed to generate a reverse-proxy with this library.

安装

$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway
$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger
$ go get -u github.com/golang/protobuf/protoc-gen-go

Make sure that your $GOPATH/bin is in your $PATH.

使用

  1. 编写 .proto 文件

  2. 添加自定义结构

syntax = "proto3";
 package example;
+
+import "google/api/annotations.proto";
+
 message StringMessage {
   string value = 1;
 }
    
 service YourService {
-  rpc Echo(StringMessage) returns (StringMessage) {}
+  rpc Echo(StringMessage) returns (StringMessage) {
+    option (google.api.http) = {
+      post: "/v1/example/echo"
+      body: "*"
+    };
+  }
 }
  1. 生成 gRPC stub

注意修改 path/to/your_service.proto 为具体路径,可以使用 - go-grpc-middleware.proto 替代 your_service*

$ protoc -I/usr/local/include -I. \
  -I$GOPATH/src \
  -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --go_out=plugins=grpc:. \
  path/to/your_service.proto
  1. Implement your service in gRPC as usual

  2. 生成代理路由

*注意修改 path/to/your_service.proto 为具体路径,可以使用 .proto 替代 your_service

$ protoc -I/usr/local/include -I. \
  -I$GOPATH/src \
  -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --grpc-gateway_out=logtostderr=true:. \
  path/to/your_service.proto

It will generate a reverse proxy path/to/your_service.pb.gw.go.

Note: After generating the code for each of the stubs, in order to build the code, you will want to run go get .from the directory containing the stubs.

  1. 编写入口
package main

import (
  "flag"
  "net/http"
   
  "github.com/golang/glog"
  "golang.org/x/net/context"
  "github.com/grpc-ecosystem/grpc-gateway/runtime"
  "google.golang.org/grpc"
    
  gw "path/to/your_service_package"
)
   
var (
  echoEndpoint = flag.String("echo_endpoint", "localhost:9090", "endpoint of YourService")
)
   
func run() error {
  ctx := context.Background()
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()
   
  mux := runtime.NewServeMux()
  opts := []grpc.DialOption{grpc.WithInsecure()}
  err := gw.RegisterYourServiceHandlerFromEndpoint(ctx, mux, *echoEndpoint, opts)
  if err != nil {
    return err
  }
   
  return http.ListenAndServe(":8080", mux)
}
   
func main() {
  flag.Parse()
  defer glog.Flush()
   
  if err := run(); err != nil {
    glog.Fatal(err)
  }
}
  1. (Optional)生成 API 文档,(推荐)
$ protoc -I/usr/local/include -I. \
  -I$GOPATH/src \
  -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --swagger_out=logtostderr=true:. \
  path/to/your_service.proto

生成的文档可以去 https://editor.swagger.io/ 预览

  1. 可以设置返回 http body

    1. Create a mux with the HTTP Body Marshaler as option.

    mux := runtime.NewServeMux(runtime.SetHTTPBodyMarshaler)

    1. Define your service in gRPC with an httpbody response message
    import "google/api/httpbody.proto";
    import "google/api/annotations.proto";
    import "google/protobuf/empty.proto";
    
    service HttpBodyExampleService {
    
        rpc HelloWorld(google.protobuf.Empty) returns (google.api.HttpBody) {
            option (google.api.http) = {
                get: "/helloworld"
            };
        }   
    
    }
    
    func (*HttpBodyExampleService) Helloworld(ctx context.Context, in *empty.Empty) (*httpbody.HttpBody, error) {
        return &httpbody.HttpBody{
            ContentType: "text/html",
            Data:        []byte("Hello World"),
        }, nil
    }
    

一些问题

  1. 默认情况下常用的 http header 会添加前缀 grpcgateway- 名称,对于自定义的 header 会保持原名称,可以通过编写自定义方法实现自定义这个方法
  2. gateway 返回的 json,如果某字段为空,则 json 会没有该字段,可以通过 EmitDefaults:true 来解决这个问题
  3. .proto 文件中为了兼容 js Number 格式,int64 会以 string 的形式返回,int32 会以 number 返回

runtime.NewServeMux 可以添加选项,默认 http header 的处理方式是 DefaultHeaderMatcher() runtime/mux.go#L51,可以自己编写指定的 http header 处理方法,

以上两个问题可以通过以下方法解决

mux := runtime.NewServeMux(
        runtime.WithIncomingHeaderMatcher(ggrpc.GatewayHaderMatcher),
        runtime.WithMarshalerOption(runtime.MIMEWildcard,
            &runtime.JSONPb{OrigName: true, EmitDefaults: true},
        ),
        runtime.SetHTTPBodyMarshaler, // 建议放在最后, http.body 解析
    )

对于 http 一些鉴权,需要用到一些 http 中间件,可以通过下面这个取巧的方法解决,以 Gin 为例。

ginRouter := gin.New()
mux := runtime.NewServeMux()
ginRouter.Any("/api/v1/*any", gin.WrapF(mux.ServeHTTP))

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 中间件处理
        c.Next()
    }
}

常用的架构是,http 先进到 Gin,通过 Gin 中间件处理够交付到 gateway 中,gateway 通过和 gRPC 连接返回 gRPC 方法的处理结果,然后返回给 Gin,返回给用户

对于 proto3 路由的一些示例

service DNSServer {
  // 获得域名列表
  rpc DomainsList(RequestDomainsList) returns (ResponseDomainList){
    option (google.api.http) = {
      get: "/api/v1/domains"
    };
  }

  // 创建新的域名
  rpc DomainCreate(RequestDomainCreate) returns (ResponseDomainCreate) {
    option (google.api.http) = {
      post: "/api/v1/domains",
      body: "*"
    };
  }

  // 域名删除
  rpc DomainDelete(RequestDomainDelete) returns (ResponseDomainDelete) {
    option (google.api.http) = {
      delete: "/api/v1/domain/{id}",
    };
  }
}

message RequestDomainDelete {
  int64 id = 2;
}

注意 rpc DomainDelete(RequestDomainDelete) returns (ResponseDomainDelete) 结构体,路由的 {id} 将会赋值到 RequestDomainDelete 的 id 中,例如 访问/api/v1/domain/123则 id = 123,如果访问 /api/v1/domain/ 或者 /api/v1/domain/uuu则会报错,因为不符合路由标准

示例代码:

参考链接

顺序即是推荐阅读顺序

官方 Doc 路径