• gRPC之gRPC认证


    1、gRPC认证

    前面篇章的gRPC都是明文传输的,容易被篡改数据,本章将介绍如何为gRPC添加安全机制

    gRPC默认内置了两种认证方式:

    • SSL/TLS认证方式

    • 基于Token的认证方式

    同时,gRPC提供了接口用于扩展自定义认证方式。

    1.1 TLS认证

    1.1.1 什么是TLS

    TLS(Transport Layer Security,安全传输层),TLS是建立在传输层TCP协议之上的协议,服务于应用层,它的前

    身是SSL(Secure Socket Layer,安全套接字层),它实现了将应用层的报文进行加密后再交由TCP进行传输的功

    能。

    1.1.2 TLS的作用

    TLS协议主要解决如下三个网络安全问题。

    • 保密(message privacy),保密通过加密encryption实现,所有信息都加密传输,第三方无法嗅探;

    • 完整性(message integrity),通过MAC校验机制,一旦被篡改,通信双方会立刻发现;

    • 认证(mutual authentication),双方认证,双方都可以配备证书,防止身份被冒充;

    这里实现TLS认证机制,首先需要准备证书,在tls_demo目录新建keys目录用于存放证书文件。

    1.1.3 证书制作

    openSSL下载安装地址:http://slproweb.com/products/Win32OpenSSL.html

    (1)、制作私钥 (server.key)

    # 生成RSA私钥
    [root@zsx keys]# openssl genrsa -out server.key 2048
    Generating RSA private key, 2048 bit long modulus
    .................+++
    ...................+++
    e is 65537 (0x10001)
    # 或者可以生成ECC私钥
    # 生成ECC私钥,命令为椭圆曲线密钥参数生成及操作,这里ECC曲线选择的是secp384r1
    openssl ecparam -genkey -name secp384r1 -out server.key
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    [root@zsx keys]# ls
    server.key
    
    • 1
    • 2

    (2)、自签名公钥(server.pem)

    会生成serve.pem,其中Common Name也就是域名,我填的是xgrpc.com

    [root@zsx keys]# openssl req -new -x509 -sha256 -key server.key -out server.pem -days 3650
    You are about to be asked to enter information that will be incorporated
    into your certificate request.
    What you are about to enter is what is called a Distinguished Name or a DN.
    There are quite a few fields but you can leave some blank
    For some fields there will be a default value,
    If you enter '.', the field will be left blank.
    -----
    Country Name (2 letter code) [XX]:cn
    State or Province Name (full name) []:tj
    Locality Name (eg, city) [Default City]:tj
    Organization Name (eg, company) [Default Company Ltd]:ndty
    Organizational Unit Name (eg, section) []:ndty
    Common Name (eg, your name or your server's hostname) []:xgrpc.com
    Email Address []:2420309401@qq.com
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • openssl req生成自签名证书
    • -new指生成证书请求
    • -sha256指使用sha256加密
    • -key指定私钥文件
    • -x509指输出证书
    • -days 3650为有效期
    • -out输出证书的文件名
    [root@zsx keys]# ls
    server.key  server.pem
    # 使用方式
    # credentials.NewServerTLSFromFile("server.pem","server.key")
    
    • 1
    • 2
    • 3
    • 4

    上面的两个步骤是不带密码的,可以生成带密码的,这里只简单的列举命令,具体的使用请参考下面SAN证书生

    成:

    # 1、生成CA私钥(ca.key)
    openssl genrsa -des3 -out ca.key 2048 
    # 2、生成CA证书签名请求(ca.csr)
    openssl req -new -key ca.key -out ca.csr
    # 该命令需要输入密码,如果不想输入命令简单使用可以先执行下面的这条命令,在执行该命令
    # 这条命令会去掉密码
    # openssl rsa -in ca.key -out ca.key
    # 生成自签名CA证书(ca.cert)
    openssl x509 -req -days 3650 -in ca.csr -signkey ca.key -out ca.crt
    # 生成的ca.key和ca.crt就可以使用了
    # credentials.NewServerTLSFromFile("ca.crt","ca.key")
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    go1.15 版本开始废弃CommonName,因此推荐使用SAN证书。

    如果想兼容之前的方式,需要设置环境变量 GODEBUGx509ignoreCN=0

    否则将会运行报错:

    rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: x509: certificate relies on legacy Common Name field, use SANs or temporarily enable Common Name matching with GODEBUG=x509ignoreCN=0"
    
    • 1
    1.1.4 使用openssl生成SAN证书

    SAN(Subject Alternative Name)是 SSL 标准 x509 中定义的一个扩展。使用了 SAN 字段的 SSL 证书,可以扩

    展此证书支持的域名,使得一个证书可以支持多个不同域名的解析。

    由于Golang 1.17以上强制使用SAN证书,故需要在此进行生成。

    1、创建一个cert目录用于保存证书和配置文件。

    2、创建配置文件(openssl.cnf),并保存到cert目录下,内容如下:

    [CA_default]
    copy_extensions = copy
    
    [req]
    distinguished_name = req_distinguished_name
    x509_extensions = v3_req
    prompt = no
    
    [req_distinguished_name]
    # 国家
    C = CN
    # 省份
    ST = Shenzhen
    # 城市
    L = Shenzhen
    # 组织
    O = Arvin
    # 部门
    OU = Arvin
    # 域名
    CN = test.example.com
    
    [v3_req]
    basicConstraints = CA:FALSE
    keyUsage = nonRepudiation,digitalSignature,keyEncipherment
    subjectAltName = @alt_names
    
    [alt_names]
    # 解析域名
    DNS.1 = *.test.example.com
    # 可配置多个域名
    DNS.2 = *.example.com
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    3、生成根证书(rootCa

    使用命令行工具,进入到cert目录下,并执行如下命令:

    # 生成私钥,密码可以输入123456
    [root@zsx cert]# openssl genrsa -des3 -out ca.key 2048
    Generating RSA private key, 2048 bit long modulus
    ..........................................+++
    ....+++
    e is 65537 (0x10001)
    Enter pass phrase for ca.key:123456
    Verifying - Enter pass phrase for ca.key:123456
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    # 使用私钥来签名证书
    [root@zsx cert]# openssl req -new -key ca.key -out ca.csr
    Enter pass phrase for ca.key:123456
    You are about to be asked to enter information that will be incorporated
    into your certificate request.
    What you are about to enter is what is called a Distinguished Name or a DN.
    There are quite a few fields but you can leave some blank
    For some fields there will be a default value,
    If you enter '.', the field will be left blank.
    -----
    Country Name (2 letter code) [XX]:CN
    State or Province Name (full name) []:Shenzhen
    Locality Name (eg, city) [Default City]:Shenzhen
    Organization Name (eg, company) [Default Company Ltd]:Arvin
    Organizational Unit Name (eg, section) []:Arvin
    Common Name (eg, your name or your server's hostname) []:test.example.com
    Email Address []:2420309401@qq.com
    
    Please enter the following 'extra' attributes
    to be sent with your certificate request
    A challenge password []: # 回车即可
    An optional company name []: # 回车即可
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    # 使用私钥+证书来生成公钥
    [root@zsx cert]# openssl x509 -req -days 365 -in ca.csr -signkey ca.key -out ca.crt
    Signature ok
    subject=/C=CN/ST=Shenzhen/L=Shenzhen/O=Arvin/OU=Arvin/CN=test.example.com/emailAddress=2420309401@qq.com
    Getting Private key
    Enter pass phrase for ca.key:123456
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    [root@zsx cert]# ls
    ca.crt  ca.csr  ca.key  openssl.cnf
    
    • 1
    • 2

    4、在cert目录下,分别创建serverclient目录,它们用来保存服务器密钥与客户端密钥。

    5、生成服务器密钥

    使用命令行工具,进入到cert目录下,并执行如下命令:

    # 生成服务器私钥
    [root@zsx cert]# openssl genpkey -algorithm RSA -out server/server.key
    ................++++++
    ....++++++
     
    # 使用私钥来签名证书
    [root@zsx cert]# openssl req -new -nodes -key server/server.key -out server/server.csr -config openssl.cnf -extensions v3_req
     
    # 生成SAN证书
    $ [root@zsx cert]# openssl x509 -req -in server/server.csr -out server/server.pem -CA ca.crt -CAkey ca.key -CAcreateserial -extfile ./openssl.cnf -extensions v3_req
    Signature ok
    subject=/C=CN/ST=Shenzhen/L=Shenzhen/O=Arvin/OU=Arvin/CN=test.example.com
    Getting CA Private Key
    Enter pass phrase for ca.key:123456
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    6、生成客户端密钥

    使用命令行工具,进入到cert目录下,并执行如下命令:

    # 生成客户端私钥
    [root@zsx cert]# openssl genpkey -algorithm RSA -out client/client.key
    ...++++++
    ...++++++
     
    # 使用私钥来签名证书
    [root@zsx cert]# openssl req -new -nodes -key client/client.key -out client/client.csr -config openssl.cnf -extensions v3_req
     
    # 生成SAN证书
    [root@zsx cert]# openssl x509 -req -in client/client.csr -out client/client.pem -CA ca.crt -CAkey ca.key -CAcreateserial -extfile ./openssl.cnf -extensions v3_req
    Signature ok
    subject=/C=CN/ST=Shenzhen/L=Shenzhen/O=Arvin/OU=Arvin/CN=test.example.com
    Getting CA Private Key
    Enter pass phrase for ca.key:123456
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    [root@zsx protoc]# tree tls_demo/
    tls_demo/
    └── cert
        ├── ca.crt
        ├── ca.csr
        ├── ca.key
        ├── ca.srl
        ├── client
        │   ├── client.csr
        │   ├── client.key
        │   └── client.pem
        ├── openssl.cnf
        └── server
            ├── server.csr
            ├── server.key
            └── server.pem
    
    3 directories, 11 files
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    1.1.5 编写hello.proto
    // 指定proto版本
    syntax = "proto3";
    // 指定包名
    package hello;
    option go_package="./hello";
    
    // 定义Hello服务
    service Hello {
        // 定义SayHello方法
        rpc SayHello(HelloRequest) returns (HelloReply) {}
    }
    
    // HelloRequest 请求结构
    message HelloRequest {
        string name = 1;
    }
    
    // HelloReply 响应结构
    message HelloReply {
        string message = 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    运行:

    [root@zsx tls_demo]# protoc --go_out=plugins=grpc:. hello.proto
    
    • 1
    1.1.6 服务端server.go
    package main
    
    import (
    	"context"
    	"fmt"
    	pb "tls_demo/hello"
    	"google.golang.org/grpc"
    	// 引入grpc认证包
    	"google.golang.org/grpc/credentials"
    	"net"
      "log"
    )
    
    const (
    	// Address gRPC服务地址
    	Address = "127.0.0.1:50052"
    )
    
    // 定义helloService并实现约定的接口
    type helloService struct{}
    
    // HelloService Hello服务
    var HelloService = helloService{}
    
    // SayHello 实现Hello服务接口
    func (h helloService) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    	resp := new(pb.HelloReply)
    	resp.Message = fmt.Sprintf("Hello %s.", in.Name)
    	return resp, nil
    }
    
    func main() {
    	log.Println("服务端启动!")
    	listen, err := net.Listen("tcp", Address)
    	if err != nil {
    		log.Fatalf("Failed to listen: %v", err)
    	}
    	// TLS认证
    	creds, err := credentials.NewServerTLSFromFile("./cert/server/server.pem", "./cert/server/server.key")
    	if err != nil {
    		log.Fatalf("Failed to generate credentials %v", err)
    	}
    	// 实例化grpc Server, 并开启TLS认证
    	s := grpc.NewServer(grpc.Creds(creds))
    	// 注册HelloService
    	pb.RegisterHelloServer(s, HelloService)
    	log.Println("Listen on " + Address + " with TLS")
    	s.Serve(listen)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • credentials.NewServerTLSFromFile:从输入证书文件和密钥文件为服务端构造TLS凭证

    • grpc.Creds:返回一个ServerOption,用于设置服务器连接的凭证。

    运行:

    [root@zsx tls_demo]# go run server.go
    2023/02/11 09:55:59 服务端启动!
    2023/02/11 09:55:59 Listen on 127.0.0.1:50052 with TLS
    
    • 1
    • 2
    • 3

    服务端在实例化grpc Server时,可配置多种选项,TLS认证是其中之一。

    1.1.7 客户端client.go
    package main
    
    import (
    	"context"
    	// 引入proto包
    	pb "tls_demo/hello"
    	"google.golang.org/grpc"
    	// 引入grpc认证包
    	"google.golang.org/grpc/credentials"
    	"log"
    )
    
    const (
    	// Address gRPC服务地址
    	Address = "127.0.0.1:50052"
    )
    
    func main() {
    	log.Println("客户端连接!")
    	// TLS连接
    	creds, err := credentials.NewClientTLSFromFile("./cert/server/server.pem", "test.example.com")
    	if err != nil {
    		log.Fatalf("Failed to create TLS credentials %v", err)
    	}
    	conn, err := grpc.Dial(Address, grpc.WithTransportCredentials(creds))
    	if err != nil {
    		log.Fatalln("err:", err)
    	}
    	defer conn.Close()
    	// 初始化客户端
    	c := pb.NewHelloClient(conn)
    	// 调用方法
    	req := &pb.HelloRequest{Name: "gRPC"}
    	res, err := c.SayHello(context.Background(), req)
    	if err != nil {
    		log.Fatalln(err)
    	}
    	log.Println(res.Message)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • credentials.NewClientTLSFromFile:从输入的证书文件中为客户端构造TLS凭证。

    • grpc.WithTransportCredentials:配置连接级别的安全凭证(例如,TLS/SSL),返回一个DialOption,

      用于连接服务器。

    运行:

    [root@zsx tls_demo]# go run client.go
    2023/02/11 10:00:11 客户端连接!
    2023/02/11 10:00:11 Hello gRPC.
    
    • 1
    • 2
    • 3

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

    多的选项。

    # 项目结构
    [root@zsx protoc]# tree tls_demo/
    tls_demo/
    ├── cert
    │   ├── ca.crt
    │   ├── ca.csr
    │   ├── ca.key
    │   ├── ca.srl
    │   ├── client
    │   │   ├── client.csr
    │   │   ├── client.key
    │   │   └── client.pem
    │   ├── openssl.cnf
    │   └── server
    │       ├── server.csr
    │       ├── server.key
    │       └── server.pem
    ├── client.go
    ├── go.mod
    ├── go.sum
    ├── hello
    │   └── hello.pb.go
    ├── hello.proto
    └── server.go
    
    4 directories, 17 files
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    1.2 Token认证

    到这里,已经完成TLS证书认证了,gRPC传输不再是明文传输。此外,添加自定义的验证方法能使gRPC相对更安

    全。下面以TLS + Token认证为例,介绍gRPC如何添加自定义验证方法

    1.2.1 Token认证原理

    客户端发请求时,添加Token到上下文context.Context中,服务器接收到请求,先从上下文中获取Token

    证,验证通过才进行下一步处理。

    客户端请求添加Token到上下文中:

    conn, err := grpc.Dial(Address, grpc.WithTransportCredentials(creds), grpc.WithPerRPCCredentials(&token))
    
    • 1
    type PerRPCCredentials interface {
        GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
        RequireTransportSecurity() bool
    }
    
    • 1
    • 2
    • 3
    • 4

    gRPC 中默认定义了 PerRPCCredentials,是提供用于自定义认证的接口,它的作用是将所需的安全认证信息添

    加到每个RPC方法的上下文中。其包含 2 个方法:

    • GetRequestMetadata:获取当前请求认证所需的元数据。
    • RequireTransportSecurity:是否需要基于 TLS 认证进行安全传输。
    package auth
    
    import (
    	"context"
    )
    
    // Token token认证
    type Token struct {
    	AppID     string
    	AppSecret string
    }
    
    // GetRequestMetadata 获取当前请求认证所需的元数据
    func (t *Token) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
    	return map[string]string{"app_id": t.AppID, "app_secret": t.AppSecret}, nil
    }
    
    // RequireTransportSecurity 是否需要基于 TLS 认证进行安全传输
    func (t *Token) RequireTransportSecurity() bool {
    	return true
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    //构建Token
    token := auth.Token{
        AppID: "grpc_token",
        AppSecret: "123456",
    }
    // 连接服务器
    conn, err := grpc.Dial(Address, grpc.WithTransportCredentials(creds),grpc.WithPerRPCCredentials(&token))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    1.2.2 服务端tserver.go
    package main
    
    import (
    	"fmt"
    	pb "tls_demo/hello"
    	"golang.org/x/net/context"
    	"google.golang.org/grpc"
    	"google.golang.org/grpc/codes"
    	"google.golang.org/grpc/metadata"
    	// 引入grpc认证包
    	"google.golang.org/grpc/credentials"
    	"log"
    	"net"
    )
    
    const (
    	// Address gRPC服务地址
    	Address = "127.0.0.1:50052"
    )
    
    // 定义helloService并实现约定的接口
    type helloService struct{}
    
    // HelloService ...
    var HelloService = helloService{}
    
    // SayHello 实现Hello服务接口
    func (h helloService) 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认证信息")
    	}
        // metadata:  map[:authority:[test.example.com] appid:[101010] appkey:[I am key] content-type:[application/grpc] user-agent:[grpc-go/1.53.0]]
        log.Println("metadata: ",md)
    	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)
    	}
    	resp := new(pb.HelloReply)
    	resp.Message = fmt.Sprintf("Hello %s.\nToken info: appid=%s,appkey=%s", in.Name, appid, appkey)
    	return resp, nil
    }
    
    func main() {
    	listen, err := net.Listen("tcp", Address)
    	if err != nil {
    		log.Fatalf("failed to listen: %v", err)
    	}
    	// TLS认证
    	creds, err := credentials.NewServerTLSFromFile("./cert/server/server.pem", "./cert/server/server.key")
    	if err != nil {
    		log.Fatalf("Failed to generate credentials %v", err)
    	}
    	// 实例化grpc Server, 并开启TLS认证
    	s := grpc.NewServer(grpc.Creds(creds))
    	// 注册HelloService
    	pb.RegisterHelloServer(s, HelloService)
    	log.Println("Listen on " + Address + " with TLS + Token")
    	s.Serve(listen)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • metadata.FromIncomingContext:从上下文中获取元数据

    运行:

    [root@zsx tls_demo]# go run tserver.go
    2023/02/11 10:18:05 Listen on 127.0.0.1:50052 with TLS + Token
    
    • 1
    • 2
    1.2.3 客户端tclient.go

    这里我们定义了一个customCredential结构,并实现了两个方法GetRequestMetadata

    RequireTransportSecurity。这是gRPC提供的自定义认证方式,每次RPC调用都会传输认证信息。

    customCredential其实是实现了grpc/credential包内的PerRPCCredentials接口。每次调用,token信息会

    通过请求的metadata传输到服务端。下面具体看一下服务端如何获取metadata中的信息。

    package main
    
    import (
    	"context"
    	// 引入proto包
    	pb "tls_demo/hello"
    	"google.golang.org/grpc"
    	// 引入grpc认证包
    	"google.golang.org/grpc/credentials"
    	"log"
    )
    
    const (
    	// Address gRPC服务地址
    	Address = "127.0.0.1:50052"
    	// OpenTLS 是否开启TLS认证
    	OpenTLS = true
    )
    
    // customCredential 自定义认证
    type customCredential struct{}
    
    // GetRequestMetadata 实现自定义认证接口
    func (c customCredential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
    	return map[string]string{
    		"appid":  "101010",
    		"appkey": "I am key",
    	}, nil
    }
    
    // RequireTransportSecurity 自定义认证是否开启TLS
    func (c customCredential) RequireTransportSecurity() bool {
    	return OpenTLS
    }
    
    func main() {
    	var err error
    	var opts []grpc.DialOption
    	if OpenTLS {
    		// TLS连接
    		creds, err := credentials.NewClientTLSFromFile("./cert/server/server.pem", "test.example.com")
    		if err != nil {
    			log.Fatalf("Failed to create TLS credentials %v", err)
    		}
    		opts = append(opts, grpc.WithTransportCredentials(creds))
    	} else {
    		opts = append(opts, grpc.WithInsecure())
    	}
    	// 使用自定义认证
    	opts = append(opts, grpc.WithPerRPCCredentials(new(customCredential)))
    	conn, err := grpc.Dial(Address, opts...)
    	if err != nil {
    		log.Fatalln(err)
    	}
    	defer conn.Close()
    	// 初始化客户端
    	c := pb.NewHelloClient(conn)
    	// 调用方法
    	req := &pb.HelloRequest{Name: "gRPC"}
    	res, err := c.SayHello(context.Background(), req)
    	if err != nil {
    		log.Fatalln(err)
    	}
    	log.Println(res.Message)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65

    运行结果:

    [root@zsx tls_demo]# go run tclient.go
    2023/02/11 10:40:21 Hello gRPC.
    Token info: appid=101010,appkey=I am key
    
    • 1
    • 2
    • 3

    修改appkey的值为i am key,验证认证失败结果:

    [root@zsx tls_demo]# go run tclient.go
    2023/02/11 10:40:59 rpc error: code = Unauthenticated desc = Token认证信息无效: appid=101010, appkey=i am key
    exit status 1
    
    • 1
    • 2
    • 3
    # 项目结构
    $ tree tls_demo/
    tls_demo/
    ├── cert
    │   ├── ca.crt
    │   ├── ca.csr
    │   ├── ca.key
    │   ├── ca.srl
    │   ├── client
    │   │   ├── client.csr
    │   │   ├── client.key
    │   │   └── client.pem
    │   ├── openssl.cnf
    │   └── server
    │       ├── server.csr
    │       ├── server.key
    │       └── server.pem
    ├── client.go
    ├── go.mod
    ├── go.sum
    ├── hello
    │   └── hello.pb.go
    ├── hello.proto
    ├── server.go
    ├── tclient.go
    └── tserver.go
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    1.3 JWT认证

    1.3.1 proto编写和编译
    syntax = "proto3";
    package api;
    option go_package = "./api;api";
    
    service Ping {
        rpc Login (LoginRequest) returns (LoginReply) {}
        rpc SayHello(PingMessage) returns (PingMessage) {}
    }
    
    message LoginRequest {
        string username = 1;
        string password = 2;
    }
    
    message LoginReply {
        string status = 1;
        string token = 2;
    }
    
    message PingMessage {
        string greeting = 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    $ protoc --go_out=plugins=grpc:. api/api.proto
    
    • 1
    1.3.2 jwt工具类

    /api/authtoken.go文件的内容如下:

    package api
    
    import (
    	"context"
    	"fmt"
    	"github.com/dgrijalva/jwt-go"
    	"google.golang.org/grpc/metadata"
    	"time"
    )
    
    // 生成token
    func CreateToken(userName string) (tokenString string) {
    	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    		"iss":      "lora-app-server",
    		"aud":      "lora-app-server",
    		"nbf":      time.Now().Unix(),
    		"exp":      time.Now().Add(time.Hour).Unix(),
    		"sub":      "user",
    		"username": userName,
    	})
    	tokenString, err := token.SignedString([]byte("verysecret"))
    	if err != nil {
    		panic(err)
    	}
    	return tokenString
    }
    
    // AuthToekn自定义认证
    type AuthToekn struct {
    	Token string
    }
    
    // AuthToekn实现了该方法,相当于实现了PerRPCCredentials接口
    func (c AuthToekn) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
    	return map[string]string{
    		"authorization": c.Token,
    	}, nil
    }
    
    // AuthToekn实现了该方法,相当于实现了PerRPCCredentials接口
    // 是否验证证书
    func (c AuthToekn) RequireTransportSecurity() bool {
    	return false
    }
    
    // Claims defines the struct containing the token claims.
    type Claims struct {
    	jwt.StandardClaims
    	// Username defines the identity of the user.
    	Username string `json:"username"`
    }
    
    // Step1. 从 context 的 metadata 中,取出 token
    func getTokenFromContext(ctx context.Context) (string, error) {
    	md, ok := metadata.FromIncomingContext(ctx)
    	if !ok {
    		return "", fmt.Errorf("ErrNoMetadataInContext")
    	}
    	// md 的类型是 type MD map[string][]string
    	token, ok := md["authorization"]
    	if !ok || len(token) == 0 {
    		return "", fmt.Errorf("ErrNoAuthorizationInMetadata")
    	}
    	// 因此,token 是一个字符串数组,我们只用了 token[0]
    	return token[0], nil
    }
    
    func CheckAuth(ctx context.Context) (username string) {
    	tokenStr, err := getTokenFromContext(ctx)
    	if err != nil {
    		panic("get token from context error")
    	}
    	var clientClaims Claims
    	token, err := jwt.ParseWithClaims(tokenStr, &clientClaims, func(token *jwt.Token) (interface{}, error) {
    		if token.Header["alg"] != "HS256" {
    			panic("ErrInvalidAlgorithm")
    		}
    		return []byte("verysecret"), nil
    	})
    	if err != nil {
    		panic("jwt parse error")
    	}
    
    	if !token.Valid {
    		panic("ErrInvalidToken")
    	}
    	fmt.Println("parse token is: ", token)
    	return clientClaims.Username
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    1.3.3 逻辑处理

    api/handler.go文件的内容如下:

    package api
    
    import (
    	"fmt"
    	"golang.org/x/net/context"
    )
    
    // Server represents the gRPC server
    type Server struct {
    }
    
    // 登录处理
    func (s *Server) Login(ctx context.Context, in *LoginRequest) (*LoginReply, error) {
    	fmt.Println("Loginrequest: ", in.Username)
    	if in.Username == "gavin" && in.Password == "gavin" {
    		// 创建jwt
    		tokenString := CreateToken(in.Username)
    		fmt.Println("generate token is: ", tokenString)
    		return &LoginReply{Status: "200", Token: tokenString}, nil
    	} else {
    		return &LoginReply{Status: "403", Token: ""}, nil
    	}
    }
    
    // SayHello generates response to a Ping request
    func (s *Server) SayHello(ctx context.Context, in *PingMessage) (*PingMessage, error) {
    	msg := "bar"
    	// 逻辑处理前需要验证jwt
    	userName := CheckAuth(ctx)
    	msg += " " + userName
    	return &PingMessage{Greeting: msg}, nil
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    1.3.4 服务端
    package main
    
    import (
    	"demo/api"
    	"fmt"
    	"google.golang.org/grpc"
    	"log"
    	"net"
    )
    
    func main() {
    	lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 7777))
    	if err != nil {
    		log.Fatalf("failed to listen: %v", err)
    	}
    	s := api.Server{}
    	grpcServer := grpc.NewServer()
    	api.RegisterPingServer(grpcServer, &s)
    	if err := grpcServer.Serve(lis); err != nil {
    		log.Fatalf("failed to serve: %s", err)
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    $ go run server.go
    Loginrequest:  gavin
    generate token is:  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb3JhLWFwcC1zZXJ2ZXIiLCJleHAiOjE2NzY2MDE3MTgsImlzcyI6ImxvcmEtYXBwLXNlcnZlciIsIm5iZiI6MTY3NjU5ODExOCwic3ViIjoidXNlciIsInVzZXJuYW1lIjoiZ2F2aW4ifQ.IoAmUq2Vm90I5dWEgNEGc22c7YspVJN4cLeOWS16gaA
    parse token is:  &{eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb3JhLWFwcC1zZXJ2ZXIiLCJleHAiOjE2NzY2MDE3MTgsImlzcyI6ImxvcmEtYXBwLXNlcnZlciIsIm5iZiI6MTY3NjU5ODExOCwic3ViIjoidXNlciIsInVzZXJuYW1lIjoiZ2F2aW4ifQ.IoAmUq2Vm90I5dWEgNEGc22c7YspVJN4cLeOWS16gaA 0xc00000e600 map[alg:HS256 typ:JWT] 0xc0001684d0 IoAmUq2Vm90I5dWEgNEGc22c7YspVJN4cLeOWS16gaA true}
    
    • 1
    • 2
    • 3
    • 4
    1.3.5 客户端
    package main
    
    import (
    	"context"
    	"demo/api"
    	"fmt"
    	"google.golang.org/grpc"
    	"log"
    )
    
    func main() {
    	var conn *grpc.ClientConn
    	conn, err := grpc.Dial(":7777", grpc.WithInsecure())
    	if err != nil {
    		log.Fatalf("did not connect: %s", err)
    	}
    	defer conn.Close()
    	c := api.NewPingClient(conn)
    	loginReply, err := c.Login(context.Background(), &api.LoginRequest{Username: "gavin", Password: "gavin"})
    	if err != nil {
    		log.Fatalf("Error when calling SayHello: %s", err)
    	}
    	fmt.Println("Login Reply:", loginReply)
    	//Call SayHello
    	requestToken := new(api.AuthToekn)
    	requestToken.Token = loginReply.Token
    	conn, err = grpc.Dial(":7777", grpc.WithInsecure(), grpc.WithPerRPCCredentials(requestToken))
    	if err != nil {
    		log.Fatalf("did not connect: %s", err)
    	}
    	defer conn.Close()
    	c = api.NewPingClient(conn)
    	helloreply, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
    	if err != nil {
    		log.Fatalf("Error when calling SayHello: %s", err)
    	}
    	log.Printf("Response from server: %s", helloreply.Greeting)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    $ go run client.go
    Login Reply: status:"200"  token:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb3JhLWFwcC1zZXJ2ZXIiLCJleHAiOjE2NzY2MDE3MTgsImlzcyI6ImxvcmEtYXBwLXNlcnZlciIsIm5iZiI6MTY3NjU5ODExOCwic3ViIjoidXNlciIsInVzZXJuYW1lIjoiZ2F2aW4ifQ.IoAmUq2Vm90I5dWEgNEGc22c7YspVJN4cLeOWS16gaA"
    2023/02/17 09:41:58 Response from server: bar gavin
    
    • 1
    • 2
    • 3
    # 项目结构
    $ tree demo/
    demo/
    ├── api
    │   ├── api.pb.go
    │   ├── api.proto
    │   ├── authtoken.go
    │   └── handler.go
    ├── client1.go
    ├── echo.proto
    ├── go.mod
    ├── go.sum
    ├── proto
    │   └── echo.pb.go
    └── server1.go
    
    2 directories, 10 files
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    google.golang.org/grpc/credentials/oauth包已实现了用于Google API的oauth和jwt验证的方法,使用方

    法可以参考[官方文档]:

    https://github.com/grpc/grpc-go/blob/master/Documentation/grpc-auth-support.md

    在实际应用中,我们可以根据自己的业务需求实现合适的验证方式。

    1.4 oauth认证

    1.4.1 proto编写和编译
    syntax = "proto3";
    option go_package = "./proto";
    package proto;
    
    message EchoRequest {
        string message = 1;
    }
    
    message EchoResponse {
        string message = 1;
    }
    
    service Echo {
        rpc UnaryEcho(EchoRequest) returns (EchoResponse) {}
        rpc ServerStreamingEcho(EchoRequest) returns (stream EchoResponse) {}
        rpc ClientStreamingEcho(stream EchoRequest) returns (EchoResponse) {}
        rpc BidirectionalStreamingEcho(stream EchoRequest) returns (stream EchoResponse) {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    $ protoc --go_out=plugins=grpc:. echo.proto
    
    • 1
    1.4.2 服务端编写
    package main
    
    import (
    	"context"
    	"crypto/tls"
    	pb "demo/proto/proto"
    	"flag"
    	"fmt"
    	"google.golang.org/grpc"
    	"google.golang.org/grpc/codes"
    	"google.golang.org/grpc/credentials"
    	"google.golang.org/grpc/metadata"
    	"google.golang.org/grpc/status"
    	"log"
    	"net"
    	"strings"
    )
    
    var (
    	errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
    	errInvalidToken    = status.Errorf(codes.Unauthenticated, "invalid token")
    )
    
    var port = flag.Int("port", 50051, "the port to serve on")
    
    func main() {
    	flag.Parse()
    	fmt.Printf("server starting on port %d...\n", *port)
    	cert, err := tls.LoadX509KeyPair("./cert/server/server.pem", "./cert/server/server.key")
    	if err != nil {
    		log.Fatalf("failed to load key pair: %s", err)
    	}
    	opts := []grpc.ServerOption{
    		grpc.UnaryInterceptor(ensureValidToken),
    		grpc.Creds(credentials.NewServerTLSFromCert(&cert)),
    	}
    	s := grpc.NewServer(opts...)
    	pb.RegisterEchoServer(s, &ecServer{})
    	lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
    	if err != nil {
    		log.Fatalf("failed to listen: %v", err)
    	}
    	if err := s.Serve(lis); err != nil {
    		log.Fatalf("failed to serve: %v", err)
    	}
    }
    
    type ecServer struct {
    	pb.UnimplementedEchoServer
    }
    
    func (s *ecServer) UnaryEcho(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
    	return &pb.EchoResponse{Message: req.Message}, nil
    }
    
    func valid(authorization []string) bool {
    	if len(authorization) < 1 {
    		return false
    	}
    	token := strings.TrimPrefix(authorization[0], "Bearer ")
    	return token == "some-secret-token"
    }
    
    func ensureValidToken(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    	md, ok := metadata.FromIncomingContext(ctx)
    	if !ok {
    		return nil, errMissingMetadata
    	}
    	if !valid(md["authorization"]) {
    		return nil, errInvalidToken
    	}
    	return handler(ctx, req)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    [root@zsx demo]# go run server.go
    server starting on port 50051...
    
    • 1
    • 2
    1.4.3 客户端编写
    package main
    
    import (
    	"context"
    	ecpb "demo/proto/proto"
    	"flag"
    	"fmt"
    	"golang.org/x/oauth2"
    	"google.golang.org/grpc"
    	"google.golang.org/grpc/credentials"
    	"google.golang.org/grpc/credentials/oauth"
    	"log"
    	"time"
    )
    
    var addr = flag.String("addr", "localhost:50051", "the address to connect to")
    
    func callUnaryEcho(client ecpb.EchoClient, message string) {
    	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    	defer cancel()
    	resp, err := client.UnaryEcho(ctx, &ecpb.EchoRequest{Message: message})
    	if err != nil {
    		log.Fatalf("client.UnaryEcho(_) = _, %v: ", err)
    	}
    	fmt.Println("UnaryEcho: ", resp.Message)
    }
    
    func main() {
    	flag.Parse()
    	perRPC := oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(fetchToken())}
    	creds, err := credentials.NewClientTLSFromFile("./cert/server/server.pem", "x.test.example.com")
    	if err != nil {
    		log.Fatalf("failed to load credentials: %v", err)
    	}
    	opts := []grpc.DialOption{
    		grpc.WithPerRPCCredentials(perRPC),
    		grpc.WithTransportCredentials(creds),
    	}
    	conn, err := grpc.Dial(*addr, opts...)
    	if err != nil {
    		log.Fatalf("did not connect: %v", err)
    	}
    	defer conn.Close()
    	rgc := ecpb.NewEchoClient(conn)
    	callUnaryEcho(rgc, "hello world")
    }
    
    func fetchToken() *oauth2.Token {
    	return &oauth2.Token{
    		AccessToken: "some-secret-token",
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    [root@zsx demo]# go run client.go
    UnaryEcho:  hello world
    
    • 1
    • 2
    # 项目结构
    [root@zsx protoc]# tree demo/
    demo/
    ├── cert
    │   ├── ca.crt
    │   ├── ca.csr
    │   ├── ca.key
    │   ├── ca.srl
    │   ├── client
    │   │   ├── client.csr
    │   │   ├── client.key
    │   │   └── client.pem
    │   ├── openssl.cnf
    │   └── server
    │       ├── server.csr
    │       ├── server.key
    │       └── server.pem
    ├── client.go
    ├── go.mod
    ├── go.sum
    ├── proto
    │   ├── echo.proto
    │   └── proto
    │       └── echo.pb.go
    └── server.go
    
    5 directories, 17 files
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    参考地址:https://godoc.org/google.golang.org/grpc/credentials/oauth

  • 相关阅读:
    学生个人单页面网页作业 学生网页设计成品 静态HTML网页单页制作 dreamweaver网页设计与制作代码 web前端期末大作业
    数据库误修改后的数据恢复
    C#教程12:结构
    1236288-25-7,DSPE-PEG-FA,Folic acid PEG DSPE,磷脂-聚乙二醇-叶酸脂质体形成材料
    [激光原理与应用-24]:《激光原理与技术》-10- 控制技术-调Q技术、Q开关、Q驱动器
    拓端tecdat|R语言时间序列分析复杂的季节模式
    PowerQuery 多级目录数据合并,并将目录转化为字段
    矩阵与线性变换
    原辅料采购进厂----药品生产管理系统之化药原辅料
    java108-StringBuilder连接字符串和删除操作
  • 原文地址:https://blog.csdn.net/qq_30614345/article/details/132548746