c#:grpc初体验

环境:

  • window10x64 企业版
  • vs2019 16.7.7
  • .net core3.1

参照:
《ASP.NET Core 3.0 使用gRPC》
《Asp.Net Core Grpc使用C#对象取代Proto定义》

实验代码下载:
https://download.csdn.net/download/u010476739/13090566

一、什么是grpc?

首先,RPC(Remote Procedure Call)是用来实现不同服务之间的调用的。像之前的microsoft .net remoting、WCF、WebService都是这类功能。
然后,随着http和web前端的迅速发展,也越来越多的服务接口采用restful设计。
现在,微服务概念盛行,伴随着而来的就是微服务之间的互相调用,那么google就自己开发了一套微服务间通信的标准,称之为:GRPC。

二、grpc有什么特点?

  • 现代高性能轻量级 RPC 框架。
  • 使用 HTTP/2 进行传输
  • 通过Protocol Buffers二进制序列化减少网络使用
  • 约定优先的 API 开发,默认使用 Protocol Buffers 作为描述语言,允许与语言无关的实现。

三、创建一个grpc服务

新建一个空白解决方案:grpc-trial,然后新建一个空的web工程grpc-server1,如下:
在这里插入图片描述
修改grpc-server1工程:

添加nuget包Grpc.AspNetCore

<ItemGroup>
  <PackageReference Include="Grpc.AspNetCore" Version="2.27.0" />
</ItemGroup>

我们在解决方案目录下新建文件夹Protos
在这里插入图片描述

在这个文件夹下,新建文件TestServer.proto,内容如下:

syntax = "proto3";

// 命名空间
option csharp_namespace = "Protos";

package Protos;

// 服务接口
service TestServer {
  // 接口内的方法
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// 请求参数模型
message HelloRequest {
  string name = 1;
}

// 响应参数模型
message HelloReply {
  string message = 1;
}

将文件夹Protos下的内容全部包含到grpc-server1工程下:

<ItemGroup>
	<Protobuf Include="..\Protos\**\*.proto" GrpcServices="Server" Link="Protos\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

效果如下图所示:
在这里插入图片描述
在工程上新建服务类TestServer,如下图所示:
在这里插入图片描述
服务类的代码如下:

using Grpc.Core;
using Microsoft.Extensions.Logging;
using Protos;
using System.Threading.Tasks;

namespace grpc_server1.GrpcServices
{
    public class TestServer : Protos.TestServer.TestServerBase
    {
        private readonly ILogger<TestServer> _logger;
        public TestServer(ILogger<TestServer> logger)
        {
            _logger = logger;
        }

        public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
        {
			_logger.LogInformation($"接受到了请求:{request.Name}");
            return Task.FromResult(new HelloReply
            {
                Message = "Hello " + request.Name
            });
        }
    }
}

Protos.TestServer.TestServerBase这个类是怎么来的?vs根据TestServer.proto文件自动生成的。

由于grpc是用的http2,所以我们在appsettings.json中配置Kestrel使用http2:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    }
  }
}

现在,让我们在web程序中引入grpcStartup.csConfigureServices中注入grpc服务:

 public void ConfigureServices(IServiceCollection services)
 {
     services.AddGrpc();
 }

Configure方法中,注入TestServer服务

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGrpcService<TestServer>();
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

好了,现在让我们编译并启动它吧!
在这里插入图片描述

四、创建一个客户端,调用grpc服务

在解决方案上新建.net core控制台项目TestClient1
修改工程TestClient1

引入nuget包Grpc.Net.ClientGoogle.Protobuf以及Grpc.Tools

<ItemGroup>
  <PackageReference Include="Google.Protobuf" Version="3.13.0" />
  <PackageReference Include="Grpc.Net.Client" Version="2.33.1" />
  <PackageReference Include="Grpc.Tools" Version="2.33.1">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  </PackageReference>
</ItemGroup>

然后也将Protos文件夹下的*.proto文件包含进来(注意GrpcServices属性的值):

<ItemGroup>
	<Protobuf Include="..\Protos\**\*.proto" GrpcServices="Client" Link="Protos\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

最终如下:
在这里插入图片描述
在Program.cs中编写代码:

using Grpc.Net.Client;
using Protos;
using System;
using System.Threading.Tasks;

namespace TestClient1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var channel = GrpcChannel.ForAddress("https://localhost:5001");
            var client = new TestServer.TestServerClient(channel);

            var reply = await client.SayHelloAsync(
                new HelloRequest { Name = "jackletter" });
            Console.WriteLine("Greeter 服务返回数据: " + reply.Message);
            Console.WriteLine("ok");
            Console.ReadLine();
        }
    }
}

五、测试效果

编译并运行工程TestClient1如下:

在这里插入图片描述

而grpc服务端输出如下:
在这里插入图片描述

到此,一个简单的grpc调用就完成了。

六、给客户端和服务端加上拦截器

拦截器就像MVC的过滤器或者是ASP.NET Core middleware 一样,具有面向切面的思想,可以在调用服务的时候进行一些统一处理, 很适合在这里处理验证、日志等流程。

Interceptor类是gRPC服务拦截器的基类,是一个抽象类,它定了几个虚方法,分别如下:

//拦截阻塞调用
public virtual TResponse BlockingUnaryCall<TRequest, TResponse>();
//拦截异步调用
public virtual AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>();
//拦截异步服务端流调用
public virtual AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>();
//拦截异步客户端流调用
public virtual AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>();
//拦截异步双向流调用
public virtual AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>();
//用于拦截和传入普通调用服务器端处理程序
public virtual Task<TResponse> UnaryServerHandler<TRequest, TResponse>();
//用于拦截客户端流调用的服务器端处理程序
public virtual Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>();
//用于拦截服务端流调用的服务器端处理程序
public virtual Task ServerStreamingServerHandler<TRequest, TResponse>();
//用于拦截双向流调用的服务器端处理程序
public virtual Task DuplexStreamingServerHandler<TRequest, TResponse>();

6.1 客户端拦截器

TestClient1工程上新建类:ClientLoggerInterceptor

public class ClientLoggerInterceptor : Interceptor
{
    public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
    {
        LogCall(context.Method);
        return continuation(request, context);
    }
    private void LogCall<TRequest, TResponse>(Method<TRequest, TResponse> method)
    where TRequest : class
    where TResponse : class
    {
        var initialColor = Console.ForegroundColor;
        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine($"Starting call. Type: {method.Type}. Request: {typeof(TRequest)}. Response: {typeof(TResponse)}");
        Console.ForegroundColor = initialColor;
    }
}

在客户端上注册拦截器,修改Program代码:

class Program
{
    static async Task Main(string[] args)
    {
        var channel = GrpcChannel.ForAddress("https://localhost:5001");
        var invoker = channel.Intercept(new ClientLoggerInterceptor());
        var client = new TestServer.TestServerClient(invoker);

        var reply = await client.SayHelloAsync(
            new HelloRequest { Name = "jackletter" });
        Console.WriteLine("Greeter 服务返回数据: " + reply.Message);
        Console.WriteLine("ok");
    }
}

6.2 服务端拦截器

grpc-server1工程上新建类ServerLoggerInterceptor,如下:

public class ServerLoggerInterceptor:Interceptor
{
    private readonly ILogger<ServerLoggerInterceptor> logger;

    public ServerLoggerInterceptor(ILogger<ServerLoggerInterceptor> logger)
    {
        this.logger = logger;
    }
    public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
    {
        LogCall<TRequest, TResponse>(MethodType.Unary, context);
        return continuation(request, context);
    }

    private void LogCall<TRequest, TResponse>(MethodType methodType, ServerCallContext context)
    where TRequest : class
    where TResponse : class
    {
        logger.LogWarning($"Starting call. Type: {methodType}. Request: {typeof(TRequest)}. Response: {typeof(TResponse)}");
    }
}

在服务端上注册拦截器,修改StartupConfigureServices方法代码:

public void ConfigureServices(IServiceCollection services)
{
    services.AddGrpc(options =>
    {
        options.Interceptors.Add<ServerLoggerInterceptor>();
    });
}

然后,编译并先后运行服务端和客户端,输出如下:

服务端:
在这里插入图片描述

客户端:
在这里插入图片描述

七、grpc流式调用

7.1 什么是grpc流?

gRPC 有四种服务类型,分别是:简单 RPC(Unary RPC)、服务端流式 RPC (Server streaming RPC)、客户端流式 RPC (Client streaming RPC)、双向流式 RPC(Bi-directional streaming RPC)。它们主要有以下特点:

  • 简单 RPC

    一般的rpc调用,传入一个请求对象,返回一个返回对象

  • 服务端流式 RPC

    传入一个请求对象,服务端可以返回多个结果对象

  • 客户端流式 RPC

    客户端传入多个请求对象,服务端返回一个结果对象

  • 双向流式 RPC

    结合客户端流式RPC和服务端流式RPC,可以传入多个请求对象,返回多个结果对象

gRPC 通信是基于 HTTP/2 实现的,它的双向流映射到 HTTP/2 流。HTTP/2 具有流的概念,流是为了实现HTTP/2的多路复用。关于http2的流概念参考:《HTTP/2笔记之流和多路复用》

7.2 写一个双向流功能

注:一开始我们写的属于简单调用。
下面我们要写一个支持双向流调用的方法。当我们调用这个方法后,我们就打开了一个双向流管道,我们可以向这个管道中写入东西,同时也可以从这个管道中读取东西(同时读写肯定需要两个线程的!)。

我们新建一个proto文件:TestStream.proto,将它放到TestServer.proto的同目录,内容如下:

syntax = "proto3";

// 命名空间
option csharp_namespace = "Protos";

package Protos;

// 服务接口
service TestStream {
  // 接口内的方法
  rpc StreamRequest (stream StreamReq) returns (stream StreamRes);
}

// 请求参数模型
message StreamReq {
  string name = 1;
}

// 响应参数模型
message StreamRes {
  string message = 1;
}

然后,我们在服务端新建类TestStream,位置如下:
在这里插入图片描述
服务类的代码如下:

namespace grpc_server1.GrpcServices
{
    public class TestStream : Protos.TestStream.TestStreamBase
    {
        private static int count = 0;
        private readonly ILogger<TestStream> logger;

        public TestStream(ILogger<TestStream> logger)
        {
            this.logger = logger;
        }

        public override async Task StreamRequest(IAsyncStreamReader<StreamReq> req, IServerStreamWriter<StreamRes> res, ServerCallContext context)
        {
            var cur = Interlocked.Increment(ref count);
            logger.LogInformation($"开始接受流式请求:{cur}");
            while (await req.MoveNext())
            {
                StreamReq currentReq = req.Current;
                logger.LogInformation($"在流式请求{cur}中,接受到了请求:{currentReq.Name}...");
                await res.WriteAsync(new StreamRes()
                {
                    Message = $"请求序列:{cur},请求参数:{currentReq.Name}"
                });
            }
            logger.LogInformation($"处理流请求{cur}完毕!");
        }
    }
}

然后,我们将这个grpc服务像TestServer一样注册进去(Startup.cs):

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
     if (env.IsDevelopment())
     {
         app.UseDeveloperExceptionPage();
     }

     app.UseRouting();

     app.UseEndpoints(endpoints =>
     {
         endpoints.MapGrpcService<TestServer>();
         endpoints.MapGrpcService<TestStream>();
         endpoints.MapGet("/", async context =>
         {
             await context.Response.WriteAsync("Hello World!");
         });
     });
 }

现在,我们修改客户端的调用代码(main方法),修改后如下:

class Program
{
    static async Task Main(string[] args)
    {
        var channel = GrpcChannel.ForAddress("https://localhost:5001");
        var invoker = channel.Intercept(new ClientLoggerInterceptor());
        //rpc简单调用=======================开始============================
        var client = new TestServer.TestServerClient(invoker);

        var reply = await client.SayHelloAsync(
            new HelloRequest { Name = "jackletter" });
        Console.WriteLine("Greeter 服务返回数据: " + reply.Message);
        //rpc简单调用========================结束============================

        //rpc双向流调用=======================开始===========================
        var streamClient = new TestStream.TestStreamClient(invoker);
        var streamCall = streamClient.StreamRequest();
        //定义流式响应处理逻辑
        var task = Task.Run(async () =>
          {
              await foreach (var resp in streamCall.ResponseStream.ReadAllAsync())
              {
                  Console.WriteLine($"收到了消息:{resp.Message}");
              }
          });
        //发送几段流式数据
        Console.WriteLine("客户端开始发送5段数据...");
        var names = new string[] { "name1", "name2", "name3", "name4", "name5" };
        for (int i = 0; i < 5; i++)
        {
            await streamCall.RequestStream.WriteAsync(new StreamReq() { Name = names[i] });
            Console.WriteLine($"客户端发送请求段完毕:{names[i]}");
            //查看控制台接受的返回
            await Task.Delay(800);
        }
        //发送完毕
        await streamCall.RequestStream.CompleteAsync();
        Console.WriteLine("客户端已发送完5段数据!");
        //等待响应结束
        await task;
        Console.WriteLine("双向流调用完成!");
        //rpc双向流调用===============================结束========================

        Console.WriteLine("ok");
        Console.ReadLine();
    }
}

7.3 测试双向流效果

编译上面两个工程,先运行服务端,然后运行客户端,效果如下图:
在这里插入图片描述

八、使用c#代码代替*.proto文件

上面的例子都是使用*.proto文件定义服务接口,这样,当我们给别人使用的时候,我们需要将*.proto文件共享给别人,然而当彼此双方都是.Neter的时候,是否直接提供给别人*.dll文件更方便呢?下面就来实验使用*.dll文件…

先准备一个空白解决方案GrpcDemo

8.1 准备接口

新建.net standard 2.1工程ShareDllInterface

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.ServiceModel.Primitives" Version="4.8.1" />
  </ItemGroup>
</Project>

定义接口如下(IHelloService.cs):

using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;

namespace ShareDllInterface
{
    [ServiceContract]
    public interface IHelloService
    {
        [OperationContract]
        ResponseMessage SayHello(RequestMessage req);

        [OperationContract]
        IAsyncEnumerable<ResponseMessage> StreamDemo(IAsyncEnumerable<RequestMessage> stream);

    }

    [DataContract]
    public class RequestMessage
    {
        [DataMember(Order = 1)]
        public string Name { get; set; }
    }

    [DataContract]
    public class ResponseMessage
    {
        [DataMember(Order = 1)]
        public string Message { set; get; }
    }
}

8.2 准备grpc服务

新建asp.net core空web工程ShareDllGrpcServer,并添加到工程ShareDllInterface的引用,然后添加protobuf-net.Grpc.AspNetCore包引用,最终*.csproj文件如下:

<Project Sdk="Microsoft.NET.Sdk.Web">
	<PropertyGroup>
		<TargetFramework>netcoreapp3.1</TargetFramework>
	</PropertyGroup>
	<ItemGroup>
		<PackageReference Include="protobuf-net.Grpc.AspNetCore" Version="1.0.123" />
	</ItemGroup>
	<ItemGroup>
		<ProjectReference Include="..\ShareDllInterface\ShareDllInterface.csproj" />
	</ItemGroup>
</Project>

新建类HelloService实现IHelloService服务:

using Microsoft.Extensions.Logging;
using ShareDllInterface;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ShareDllGrpcServer
{
    public class HelloService : IHelloService
    {
        private readonly ILogger<HelloService> logger;

        public HelloService(ILogger<HelloService> logger)
        {
            this.logger = logger;
        }
        public ResponseMessage SayHello(RequestMessage req)
        {
            logger.LogInformation($"接收到了SayHello调用:{req?.Name}");
            return new ResponseMessage()
            {
                Message = $"hello,{req?.Name}!"
            };
        }

        private int count = 0;
        public async IAsyncEnumerable<ResponseMessage> StreamDemo(IAsyncEnumerable<RequestMessage> stream)
        {

            int cur = Interlocked.Increment(ref count);
            logger.LogInformation($"接收到了StreamDemo调用:{cur}...");
            await foreach (var req in stream)
            {
                logger.LogInformation($"收到了消息:P{req.Name}");
                await Task.Delay(800);
                var res = new ResponseMessage()
                {
                    Message = $"流式调用{cur}:{req?.Name}"
                };
                yield return res;
            }
            logger.LogInformation($"客户端关闭流式调用:{cur}");
        }
    }
}

将grpc服务注入到容器中,并开启到HelloService的映射,修改Startup.cs即可,最终如下:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProtoBuf.Grpc.Server;

namespace ShareDllGrpcServer
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            //添加Grpc服务
            services.AddCodeFirstGrpc();
        }


        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGrpcService<HelloService>();
                endpoints.MapGet("/", async context =>
                {
                    await context.Response.WriteAsync("Hello World!");
                });
            });
        }
    }
}

最后,不要忘了grpc采用的是http2,所以我们在appsettings.json文件中配置Kestrel

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    }
  }
}

好了,现在grpc服务就准备完了。

8.3 准备grpc客户端

新建.net core控制台工程ShareDllClient,添加如下包引用:

<ItemGroup>
	<PackageReference Include="Grpc.Net.ClientFactory" Version="2.23.2" />
	<PackageReference Include="protobuf-net.Grpc" Version="1.0.123" />
</ItemGroup>

添加到ShareDllInterface工程的引用:

<ItemGroup>
	<ProjectReference Include="..\ShareDllInterface\ShareDllInterface.csproj" />
</ItemGroup>

我们也可以通过nuget包或直接引用dll文件。

写客户端代码如下:

using Grpc.Net.Client;
using ProtoBuf.Grpc.Client;
using ShareDllInterface;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ShareDllClient
{
    class Program
    {
        static async Task Main(string[] args)
        {
            ProtoBuf.Grpc.Client.GrpcClientFactory.AllowUnencryptedHttp2 = true;

            using var http = GrpcChannel.ForAddress("http://localhost:5000");
            var client = http.CreateGrpcService<IHelloService>();

            //简单测试
            var req = new RequestMessage()
            {
                Name = "小明"
            };
            Console.WriteLine($"发送简单消息:{req.Name}");
            var res = client.SayHello(req);
            Console.WriteLine($"收到了响应:{res.Message}");

            //流式调用
            await foreach (var reply in client.StreamDemo(SendPackage()))
            {
                Console.WriteLine($"收到流式响应:{reply.Message}");
            }
            Console.WriteLine("流式调用已结束!");
            Console.ReadLine();

        }
        static int count = 1;
        private static async IAsyncEnumerable<RequestMessage> SendPackage()
        {
            for (int i = 0; i < 5; i++)
            {
                var request = new RequestMessage
                {
                    Name = "name" + count
                };
                Console.WriteLine($"发送流式消息:{request.Name}");
                yield return request;
            }
        }
    }
}

8.4 测试效果

让我们编译并先后运行ShareDllGrpcServerShareDllClient工程吧:
在这里插入图片描述

至此,我们的初体验完成!

  • 7
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 8
    评论
C#gRPC通信可以通过使用gRPC框架来实现。gRPC是一种高性能、开源的、通用的远程过程调用(RPC)框架,它可以在多种编程语言中使用,并支持跨平台、跨语言的通信。 下面是在C#中使用gRPC进行通信的一般步骤: 1. 定义gRPC服务和消息:首先,在.proto文件中定义gRPC服务和消息。这些文件描述了服务的方法、参数和返回类型。 2. 生成代码:使用gRPC的工具生成C#的客户端和服务端代码。 3. 实现服务端:在服务端,你需要实现.proto文件中定义的服务接口。这些接口将作为服务实现的基础。 4. 实现客户端:在客户端,你可以使用生成的客户端代码来调用服务器上定义的方法。 5. 构建和运行:构建和运行服务端和客户端代码。 下面是一个简单的示例,展示了如何在C#中使用gRPC进行通信: 1. 创建.proto文件,比如hello.proto,包含以下内容: ``` syntax = "proto3"; service HelloService { rpc SayHello (HelloRequest) returns (HelloResponse) {} } message HelloRequest { string name = 1; } message HelloResponse { string message = 1; } ``` 2. 使用gRPC的工具生成C#代码。在命令行中执行以下命令: ``` protoc -I .\protos --csharp_out .\generated .\protos\hello.proto ``` 这将在generated文件夹中生成C#代码。 3. 在服务端中实现接口。在你的服务实现类中,实现HelloService中的方法,比如: ```csharp public class HelloServiceImpl : HelloService.HelloServiceBase { public override Task<HelloResponse> SayHello(HelloRequest request, ServerCallContext context) { return Task.FromResult(new HelloResponse { Message = "Hello, " + request.Name }); } } ``` 4. 在客户端中调用服务。创建一个gRPC的客户端,并调用服务器上的方法。 ```csharp var channel = new Channel("localhost", 50051, ChannelCredentials.Insecure); var client = new HelloService.HelloServiceClient(channel); var request = new HelloRequest { Name = "John" }; var response = client.SayHello(request); Console.WriteLine(response.Message); ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

jackletter

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

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

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

打赏作者

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

抵扣说明:

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

余额充值