gRPC 的通信模式


通信模式简介


一元 RPC

一元 RPC 模式也被称为简单 RPC 模式。在该模式中,当客户端调用服务器端的远程方法时,客户端发送请求至服务器端并获得一个响应,与响应一起发送的还有状态细节以及 trailer 元数据。

在一元 RPC 模式中,gRPC 服务器端和 gRPC 客户端在通信时始终只有一个请求和一个响应,过程如下图所示:

一元 RPC 模式的服务定义如下形式:

rpc addProduct(Product) returns (ProductID);
rpc getProduct(ProductID) returns (Product);

服务端流 RPC

在服务器端流 RPC 模式中,服务器端在接收到客户端的请求消息后,会发回一个响应的序列,这种多个响应所组成的序列也被称为 “流” 。

服务端流式 RPC 除了在得到客户端请求信息后发送回一个应答流之外,在发送完所有应答后,服务端的状态详情(状态码和可选的状态信息)和可选的跟踪元数据被发送回客户端,从而标记流的结束,以此来完成服务端的工作,客户端在接收到所有服务端的应答后也完成了工作。

客户端发起一次普通的 RPC 请求,服务端通过流式响应多次发送数据集,客户端调用 Recv() 方法来接收数据集,过程如下图所示:

服务器端流 RPC 模式的服务定义如下形式:

// 服务端返回流式数据
rpc ServerStream(StreamRequest) returns (stream StreamResponse);

客户端流 RPC

在客户端流 RPC 模式中,客户端会发送多个请求给服务器端,而不再是单个请求,服务端通常(但并不必须)会在接收到客户端所有的请求后发送回一个应答,其中附带有它的状态详情和可选的跟踪数据。但服务器端不一定要等到从客户端接收到所有消息后才发送响应,在接收到流中的一条消息或几条消息之后就发送响应,也可以在读取完流中的所有消息之后再发送响应。

客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端,过程如下图所示:

客户端流 RPC 模式的服务定义如下形式:

// 客户端发送流式数据
rpc ClientStream(stream StreamRequest) returns (StreamResponse);

双向流 RPC

双向流式 RPC 的调用由客户端调用方法来初始化,而服务端则接收到客户端的元数据,方法名和截止时间,服务端可以选择发送回它的初始元数据或等待客户端发送请求。

在双向流 RPC 模式中,由客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。首个请求一定是 Client 发起,但具体交互方式(谁先谁后、一次发多少、响应多少、什么时候关闭)根据程序编写的方式来确定(可以结合协程),假设该双向流是按顺序发送,则过程如下图所示:

双向流 RPC 模式的服务定义如下形式:

// 双向流式数据
rpc SCStream(stream StreamRequest) returns (stream StreamResponse) {};

gRPC 程序示例


一元 RPC

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,proto 目录用于编写 IDL 的 unary.proto 文件,具体的目录结构如下所示:

Unary
├── client
│   └── proto
│       └── unary.proto
└── server
    └── proto
        └── unary.proto

(2)在 proto 文件夹下的 unary.proto 文件中,写入如下内容:

syntax = "proto3";

package ecommerce;

option go_package="../proto";

service ProductInfo {
    	rpc addProduct(Product) returns (ProductID);
   	 	rpc getProduct(ProductID) returns (Product);
}

message Product {
    	string id = 1;
    	string name = 2;
    	string description = 3;
    	float price = 4;
}

message ProductID {
    	string value = 1;
}

(3)为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

Unary
├── client
│   ├── go.mod
│   ├── go.sum
│   └── proto
│       ├── unary_grpc.pb.go
│       ├── unary.pb.go
│       └── unary.proto
└── server
    ├── go.mod
    ├── go.sum
    └── proto
        ├── unary_grpc.pb.go
        ├── unary.pb.go
        └── unary.proto

(4)在 server 项目目录下初始化项目( go mod init server ),编写 Server 端程序重写定义的方法,该程序的具体代码如下:

package main

import (
        "context"
        "log"
        "net"
        "github.com/gofrs/uuid"
        pb "server/proto"
        "google.golang.org/grpc"
        "google.golang.org/grpc/codes"
        "google.golang.org/grpc/status"
)

const (
        port = ":50051"
)

// server is used to implement ecommerce/product_info.
type server struct {
        productMap map[string]*pb.Product
        pb.UnimplementedProductInfoServer
}

// AddProduct implements ecommerce.AddProduct
func (s *server) AddProduct(ctx context.Context,in *pb.Product) (*pb.ProductID, error) {
        out, err := uuid.NewV4()
        if err != nil {
                return nil, status.Errorf(codes.Internal, "Error while generating Product ID", err)
        }
        in.Id = out.String()
        if s.productMap == nil {
                s.productMap = make(map[string]*pb.Product)
        }
        s.productMap[in.Id] = in
        log.Printf("Product %v : %v - Added.", in.Id, in.Name)
        return &pb.ProductID{Value: in.Id}, status.New(codes.OK, "").Err()
}

// GetProduct implements ecommerce.GetProduct
func (s *server) GetProduct(ctx context.Context, in *pb.ProductID) (*pb.Product, error) {
        product, exists := s.productMap[in.Value]
        if exists && product != nil {
                log.Printf("Product %v : %v - Retrieved.", product.Id, product.Name)
                return product, status.New(codes.OK, "").Err()
        }
        return nil, status.Errorf(codes.NotFound, "Product does not exist.", in.Value)
}

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

(4)在 client 项目目录下初始化项目( go mod init client ),编写 Client 端程序调用服务,该程序的具体代码如下:

package main

import (
        "context"
        "log"
        "time"
        pb "client/proto"
        "google.golang.org/grpc"
)

const (
        address = "localhost:50051"
)

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.NewProductInfoClient(conn)

        // Contact the server and print out its response.
        name := "Apple iPhone 11"
        description := "Meet Apple iPhone 11. All-new dual-camera system with Ultra Wide and Night mode."
        price := float32(699.00)
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        r, err := c.AddProduct(ctx, &pb.Product{Name: name, Description: description, Price: price})
        if err != nil {
                log.Fatalf("Could not add product: %v", err)
        }
        log.Printf("Product ID: %s added successfully", r.Value)

        product, err := c.GetProduct(ctx, &pb.ProductID{Value: r.Value})
        if err != nil {
                log.Fatalf("Could not get product: %v", err)
        }
        log.Printf("Product: %v", product.String())
}

(5)执行 Server 端和 Client 端的程序,分别输出如下的结果:

// Server
2023/02/14 20:09:12 Product 1cc853f1-f772-401b-af7e-37b1929f43ca : Apple iPhone 11 - Added.
2023/02/14 20:09:12 Product 1cc853f1-f772-401b-af7e-37b1929f43ca : Apple iPhone 11 - Retrieved.
// Client
2023/02/14 20:09:12 Product ID: 1cc853f1-f772-401b-af7e-37b1929f43ca added successfully
2023/02/14 20:09:12 Product: id:"1cc853f1-f772-401b-af7e-37b1929f43ca"  name:"Apple iPhone 11"  description:"Meet Apple iPhone 11. All-new dual-camera system with Ultra Wide and Night mode."  price:699

服务端流 RPC

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,proto 目录用于编写 IDL 的 stream.proto 文件,具体的目录结构如下所示:

Stream
├── client
│   └── proto
│       └── stream.proto
└── server
    └── proto
        └── stream.proto

(2)在 proto 文件夹下的 stream.proto 文件中,写入如下内容:

syntax = "proto3"; // 版本声明,使用 Protocol Buffers v3 版本

option go_package = "../proto";  // 指定生成的 Go 代码在项目中的导入路径

package pb; // 包名

// 定义服务
service StreamService {
    	rpc ServerStream(StreamRequest) returns (stream StreamResponse) {};
}

// 请求消息
message StreamRequest {
    	string name = 1;
}

// 响应消息
message StreamResponse {
    	string reply = 1;
}

为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

Stream
├── client
│   └── proto
│       ├── stream_grpc.pb.go
│       ├── stream.pb.go
│       └── stream.proto
└── server
    └── proto
        ├── stream_grpc.pb.go
        ├── stream.pb.go
        └── stream.proto

(3)在 server 目录下初始化项目( go mod init server ),编写 Server 端程序重写定义的方法,该程序的具体代码如下:

package main

import (
        "fmt"
        pb "stream_server/proto"
        "net"
        "google.golang.org/grpc"
)

// server

type server struct {
        pb.UnimplementedStreamServiceServer
}

// 返回使用多种语言打招呼
func (s *server) ServerStream(in *pb.StreamRequest, stream pb.StreamService_ServerStreamServer) error {
        words := []string{
                "你好 ",
                "hello ",
                "こんにちは ",
                "안녕하세요 ",
        }

        for _, word := range words {
                data := &pb.StreamResponse{
                        Reply: word + in.GetName(),
                }
                // 使用流引用对象的 Send() 方法将其写入流并通过返回的 nil 来标记流的结束
                if err := stream.Send(data); err != nil {
                        return err
                }
        }
        return nil
}


func main() {
        // 监听本地的 8972 端口
        lis, err := net.Listen("tcp", ":8972")
        if err != nil {
                fmt.Printf("failed to listen: %v", err)
                return
        }
        s := grpc.NewServer()                  // 创建 gRPC 服务器
        pb.RegisterStreamServiceServer(s, &server{}) // 在 gRPC 服务端注册服务

        // 启动服务
        err = s.Serve(lis)
        if err != nil {
                fmt.Printf("failed to serve: %v", err)
                return
        }
}

(4)在 client 目录下初始化项目( go mod init client ),编写 Client 端程序调用服务,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "log"
        "time"
        "io"
        pb "stream_client/proto"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

// client

const (
        defaultName = "cqupthao!"
)

var (
        addr = flag.String("addr", "127.0.0.1:8972", "the address to connect to")
        name = flag.String("name", defaultName, "Name to greet")
)


func runServerStream(c pb.StreamServiceClient) error {

        // server 端流式 RPC
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        stream, err := c.ServerStream(ctx, &pb.StreamRequest{Name: *name})
        if err != nil {
                return err
        }
        for {
                // 接收服务端返回的流式数据调用 Recv() 方法从客户端流中检索消息并持续检索到流结束为止(即收到 io.EOF 或错误时退出)
                res, err := stream.Recv()
                if err == io.EOF {
                        break
                }
                if err != nil {
                        return err
                }
                log.Printf("gRPC Stream Server: %q\n", res.GetReply())
        }

        return nil
}

func main() {
        flag.Parse()
        // 连接到 server 端,此处禁用安全传输
        conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("Do not connect: %v", err)
        }
        defer conn.Close()
        c := pb.NewStreamServiceClient(conn)

        // 执行 RPC 调用并打印收到的响应数据
        _,cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        err = runServerStream(c)
        if err != nil{
                log.Fatalf("ServerStream.err: %v", err)
        }
}

(5)执行 Server 端和 Client 端的程序,输出如下的结果:

// Client
2023/02/14 20:24:54 gRPC Stream Server: "你好 cqupthao!"
2023/02/14 20:24:54 gRPC Stream Server: "hello cqupthao!"
2023/02/14 20:24:54 gRPC Stream Server: "こんにちは cqupthao!"
2023/02/14 20:24:54 gRPC Stream Server: "안녕하세요 cqupthao!"

客户端流 RPC

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,proto 目录用于编写 IDL 的 stream.proto 文件,具体的目录结构如下所示:

Stream
├── client
│   └── proto
│       └── stream.proto
└── server
    └── proto
        └── stream.proto

(2)在 proto 文件夹下的 stream.proto 文件中,写入如下内容:

syntax = "proto3"; // 版本声明,使用 Protocol Buffers v3 版本

option go_package = "../proto";  // 指定生成的 Go 代码在项目中的导入路径

package pb; // 包名

// 定义服务
service StreamService {
    	rpc ClientStream(stream StreamRequest) returns (StreamResponse) {};
}

// 请求消息
message StreamRequest {
    	string name = 1;
}

// 响应消息
message StreamResponse {
    	string reply = 1;
}

为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

Stream
├── client
│   └── proto
│       ├── stream_grpc.pb.go
│       ├── stream.pb.go
│       └── stream.proto
└── server
    └── proto
        ├── stream_grpc.pb.go
        ├── stream.pb.go
        └── stream.proto

(3)在 server 目录下初始化项目( go mod init server ),编写 Server 端程序重写定义的方法,该程序的具体代码如下:

package main

import (
        "io"
        "fmt"
        pb "stream_server/proto"
        "net"
        "google.golang.org/grpc"
)

// server

type server struct {
        pb.UnimplementedStreamServiceServer
}

// 接收流式数据
func (s *server) ClientStream(stream pb.StreamService_ClientStreamServer) error {
        reply := "(Introduction)"
        for {
                // 接收客户端发来的流式数据
                res, err := stream.Recv()
                if err == io.EOF {
                        // 最终统一回复
                        return stream.SendAndClose(&pb.StreamResponse{
                                Reply: reply,
                        })
                }
                if err != nil {
                        return err
                }
                reply += res.GetName()
        }
}  

func main() {
        // 监听本地的 8972 端口
        lis, err := net.Listen("tcp", ":8972")
        if err != nil {
                fmt.Printf("failed to listen: %v", err)
                return
        }
        s := grpc.NewServer()                  // 创建 gRPC 服务器
        pb.RegisterStreamServiceServer(s, &server{}) // 在 gRPC 服务端注册服务

        // 启动服务
        err = s.Serve(lis)
        if err != nil {
                fmt.Printf("failed to serve: %v", err)
                return
        }
}

(4)在 client 目录下初始化项目( go mod init client ),编写 Client 端程序调用服务,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "log"
        "time"
        pb "stream_client/proto"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

// client

const (
        defaultName = "cqupthao!"
)

var (
        addr = flag.String("addr", "127.0.0.1:8972", "the address to connect to")
        name = flag.String("name", defaultName, "Name to greet")
)

func runClientStream(c pb.StreamServiceClient) error {
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        // 客户端流式 RPC
        stream, err := c.ClientStream(ctx)
        if err != nil {
                return err
        }
        names := []string{"name:cqupthao ", "school:CQUPT ", "country:China"}
        for _, name := range names {
                // 发送流式数据
                err := stream.Send(&pb.StreamRequest{Name: name})
                if err != nil {
                        log.Fatalf("c.StreamClient stream.Send(%v) failed, err: %v", name, err)
                }
        }
        res, err := stream.CloseAndRecv()
        if err != nil {
                return err
        }
        log.Printf("gRPC Stream Client: %v", res.GetReply())

        return nil
}

func main() {
        flag.Parse()
        // 连接到 server 端,此处禁用安全传输
        conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("Do not connect: %v", err)
        }
        defer conn.Close()
        c := pb.NewStreamServiceClient(conn)

        // 执行 RPC 调用并打印收到的响应数据
        _,cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        err = runClientStream(c)
        if err != nil{
                log.Fatalf("ClientStream.err: %v", err)
        }
}

(5)执行 Server 端和 Client 端的程序,输出如下的结果:

// Client
2023/02/14 20:31:27 gRPC Stream Client: (Introduction) name:cqupthao school:CQUPT country:China

双向流 RPC

(1)在任意目录下,创建 serverclient 目录存放服务端和客户端文件,proto 目录用于编写 IDL 的 stream.proto 文件,具体的目录结构如下所示:

Stream
├── client
│   └── proto
│       └── stream.proto
└── server
    └── proto
        └── stream.proto

(2)在 proto 文件夹下的 stream.proto 文件中,写入如下内容:

syntax = "proto3"; // 版本声明,使用 Protocol Buffers v3 版本

option go_package = "../proto";  // 指定生成的 Go 代码在项目中的导入路径

package pb; // 包名

// 定义服务
service StreamService {
    	rpc SCStream(stream StreamRequest) returns (stream StreamResponse) {};
}

// 请求消息
message StreamRequest {
    	string name = 1;
}

// 响应消息
message StreamResponse {
    	string reply = 1;
}

为服务端和客户端生成 gRPC 源代码程序,在 proto 目录下执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

正确生成后的目录结构如下所示:

Stream
├── client
│   └── proto
│       ├── stream_grpc.pb.go
│       ├── stream.pb.go
│       └── stream.proto
└── server
    └── proto
        ├── stream_grpc.pb.go
        ├── stream.pb.go
        └── stream.proto

(3)在 server 目录下初始化项目( go mod init server ),编写 Server 端程序重写定义的方法,该程序的具体代码如下:

package main

import (
        "io"
        "strings"
        "fmt"
        pb "stream_server/proto"
        "net"
        "google.golang.org/grpc"
)

// server

type server struct {
        pb.UnimplementedStreamServiceServer
}

// 双向流式打招呼,StreamService_SCStreamServer 参数是客户端和服务端之间消息流的引用对象
func (s *server) SCStream(stream pb.StreamService_SCStreamServer) error {
        for {
                // 接收流式请求
                in, err := stream.Recv()
                if err == io.EOF {
                        return nil
                }
                if err != nil {
                        return err
                }

                reply := magic(in.GetName()) // 对收到的数据做些处理

                // 返回流式响应
                if err := stream.Send(&pb.StreamResponse{Reply: reply}); err != nil {
                        return err
                }
        }
}

// magic 一段价值连城的“人工智能”代码
func magic(s string) string {
        s = strings.ReplaceAll(s, "吗", "")
        s = strings.ReplaceAll(s, "吧", "")
        s = strings.ReplaceAll(s, "你", "我")
        s = strings.ReplaceAll(s, "?", "!")
        s = strings.ReplaceAll(s, "?", "!")
        return s
}

func main() {
        // 监听本地的 8972 端口
        lis, err := net.Listen("tcp", ":8972")
        if err != nil {
                fmt.Printf("failed to listen: %v", err)
                return
        }
        s := grpc.NewServer()                  // 创建 gRPC 服务器
        pb.RegisterStreamServiceServer(s, &server{}) // 在 gRPC 服务端注册服务

        // 启动服务
        err = s.Serve(lis)
        if err != nil {
                fmt.Printf("failed to serve: %v", err)
                return
        }
}

(4)在 client 目录下初始化项目( go mod init client ),编写 Client 端程序调用服务,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "log"
        "time"
        "io"
        "os"
        "fmt"
        "strings"
        "bufio"
        pb "stream_client/proto"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

// client

const (
        defaultName = "cqupthao!"
)

var (
        addr = flag.String("addr", "127.0.0.1:8972", "the address to connect to")
        name = flag.String("name", defaultName, "Name to greet")
)

func runSCStream(c pb.StreamServiceClient) error {
        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
        defer cancel()
        // 双向流模式
        stream, err := c.SCStream(ctx)
        if err != nil {
                return err
        }
        waitc := make(chan struct{})
        go func() {
                for {
                        // 接收服务端返回的响应
                        in, err := stream.Recv()
                        if err == io.EOF {
                                // read done.
                                close(waitc)
                                return
                        }
                        if err != nil {
                                log.Fatalf("c.SCStream stream.Recv() failed, err: %v", err)
                        }
                        fmt.Println("Server Response:", in.GetReply())
                }
        }()
        // 从标准输入获取用户输入
        reader := bufio.NewReader(os.Stdin) // 从标准输入生成读对象
        fmt.Println("Please input the message and end with an enter!")
        for {
                cmd, _ := reader.ReadString('\n') // 读到换行
                cmd = strings.TrimSpace(cmd)
                if len(cmd) == 0 {
                        continue
                }
                if strings.ToUpper(cmd) == "QUIT" {
                        break
                }
                // 将获取到的数据发送至服务端
                if err := stream.Send(&pb.StreamRequest{Name: cmd}); err != nil {
                        log.Fatalf("c.SCStream stream.Send(%v) failed: %v", cmd, err)
                }
        }
        stream.CloseSend()
        <-waitc
        return nil
}

func main() {
        flag.Parse()
        // 连接到 server 端,此处禁用安全传输
        conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("Do not connect: %v", err)
        }
        defer conn.Close()
        c := pb.NewStreamServiceClient(conn)

        // 执行 RPC 调用并打印收到的响应数据
        _,cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        err = runSCStream(c)
        if err != nil {
                log.Fatalf("SCStream.err: %v ", err)
        }
}

(5)执行 Server 端和 Client 端的程序并输入内容,输出如下的结果:

Please input the message and end with an enter!
你好吗
Server Response: 我好
在吗
Server Response: 在
走吧
Server Response: 走
go?
Server Response: go!

在 Server 端的 Send() 方法在 protoc 在生成时,根据定义生成了各式各样符合标准的接口方法,最终再统一调度内部的 SendMsg() 方法,该方法涉及以下过程:

  • 消息体(对象)序列化;

  • 压缩序列化后的消息体;

  • 对正在传输的消息体增加 5 个字节的 header ;

  • 判断压缩 + 序列化后的消息体总字节长度是否大于预设的 maxSendMessageSize(预设值为 math.MaxInt32 ),若超出则提示错误写入给流的数据集。

在 Client 端的 Recv() 方法调用 RecvMsg() 方法会从流中读取完整的 gRPC 消息体,RecvMsg() 方法是阻塞等待的,当流成功或结束时会返回 io.EOF ;当流出现任何错误时,流会被中止,错误信息会包含 RPC 错误码,而在 RecvMsg() 方法中可能出现如下错误:

  • io.EOF

  • io.ErrUnexpectedEOF

  • transport.ConnectionError

  • google.golang.org/grpc/codes

同时默认的 MaxReceiveMessageSize 值为 1024 _ 1024 _ 4 ,建议不要超出此值。


  • 参考链接:gRPC教程

  • 参考链接:gRPC 官网

  • 参考书籍:《gRPC与云原生应用开发:以Go和Java为例》([斯里兰卡] 卡山 • 因德拉西里 丹尼什 • 库鲁普 著)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

물の韜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值