gRPC的简单使用

前言

八月初的时候,在公司内部做了一个主题为《gRPC的简单使用》的分享,其实就是和小伙伴们扯扯淡,现在抽空回忆一下,也算是一个小小的总结吧。

现在市面上耳熟能详的RPC框架也很多,下面列举几个遇到比较多的。

  1. 谷歌的gRPC

  2. 推特的Thrift

  3. 阿里的Dubbo

  4. 。。。。

它们都是支持多语言的,相对来说,这三个之中,Dubbo支持的语言略微少一点。现在在一个公司内都能见到多种语言的技术栈都已经是十分常见的事了,好比我司,都有JAVA,C#,Python三种语言了,所以在多语言支持这方面,在技术选型的时候,肯定是要有所考虑的。

下面进入正式的主题,gRPC。

gRPC的简单介绍

640?wx_fmt=png

gRPC是一个现代的开源高性能RPC框架,可以在任何环境中运行。它可以高效地将数据中心内和跨数据中心的服务连接起来,并支持可插拔的负载平衡、跟踪、健康检查和身份验证。同时,它还把设备,移动应用程序和浏览器连接到后端服务的分布式计算变得很容易。

gRPC有什么优点呢?

  1. 简单的服务定义 (使用Protocol Buffers定义服务,这是一个功能强大的二进制序列化工具集和语言)

  2. 跨语言和平台工作 (在微服务式架构中有效地连接多语言服务(10+种语言支持)并能自动为各种语言和平台的服务生成惯用的客户端和服务器存根)

  3. 快速启动并扩展 (使用单行安装运行时和开发环境,并使用框架每秒扩展到数百万个RPC)

  4. 双向流媒体和集成的身份验证 (双向流媒体和集成的身份验证 基于http/2的传输的双向流和完全集成的可插拔身份验证)

gRPC在使用的时候有4种模式供我们选择

  1. 一元RPC(Unary RPCs ):这是最简单的定义,客户端发送一个请求,服务端返回一个结果

  2. 服务器流RPC(Server streaming RPCs):客户端发送一个请求,服务端返回一个流给客户端,客户从流中读取一系列消息,直到读取所有消息

  3. 客户端流RPC(Client streaming RPCs ):客户端通过流向服务端发送一系列消息,然后等待服务端读取完数据并返回处理结果

  4. 双向流RPC(Bidirectional streaming RPCs):客户端和服务端都可以独立向对方发送或接受一系列的消息。客户端和服务端读写的顺序是任意。

我们要根据具体的场景来决定选择那一种。

这里只介绍一元RPC。正常来说,一元RPC应该可以满足我们日常60~70%的需求了吧。

基本用法

gRPC的基本用法可以简单的分为三个点:

  • 服务的定义,即proto文件的编写

  • 服务端代码编写

  • 客户端代码编写

下面我们依次来看一下

服务的定义

既然要定义一个服务,肯定是知道了这个服务要完成什么事之后。

在定义之前,要对proto3和proto2有所了解。不过proto3是推荐的格式。所以我们基本上只要用proto3就可以了。

下面先来看一个后面要用到的proto文件。

syntax = "proto3";option csharp_namespace = "XXXService";package UserInfo;service UserInfoService {  rpc GetList(GetUserListRequest) returns (GetUserListReply){}  rpc GetById(GetUserByIdRequest) returns (GetUserByIdRelpy){}  rpc Save(SaveUserRequest) returns (SaveUserReply){}}message GetUserByIdRequest {    int32 id = 1;}message GetUserByIdRelpy{    int32 id = 1;    string name = 2;    int32 age = 3;    int64 create_time = 4;}message GetUserListRequest {    int32 id = 1;    string name = 2;}message GetUserListReply {  message MsgItem {    int32 id = 1;    string name = 2;    int32 age = 3;    int64 create_time = 4;   }   int32 code = 1;   string msg = 2;   repeated MsgItem data = 3;}message SaveUserRequest {    string name = 1;    int32 age = 2;}message SaveUserReply {   int32 code = 1;   string msg = 2;}

它有下面的几个部分

  1. syntax , 指定要用那个版本的语法

  2. service , 指定rpc服务的接口,简单理解成我们平时定义的接口

  3. message , 指定要传输的消息体,简单理解成我们平常用的 DTO

  4. package , 指定包名

  5. option , 可选参数的定义,不同语言有不同的选项

其实看上去还是比较容易懂的。至少一眼看过去能知道是些什么意思。

如果对proto3还没有了解的,可以参考这个文档Language Guide (proto3),里面很清楚的介绍了一些数据类型和不同语言数据类型的对应关系。

这里有一个要注意的是,时间类型,在proto3中,没有datetime类型,过去很长一段时间,我们是只能用时间戳来表示时间,也就是定义一个长整型,现在是可以用timestamp表处理了。

在写服务端和客户端代码之前,我们需要根据proto文件生成对应的代码。

一个命令即可搞定。

protoc                                                                             path/to/file.proto

现在时代进步的这么快,不少语言已经有工具做了集成,可以在build项目的时候就生成对应的文件了,不需要我们再单独去执行一次上面的那个命令。

好比我们的.NET项目,可以在ItemGroup中直接指定Protobuf,然后告诉它,proto文件是那个,是要生成服务端代码还是客户端代码。

可以看看下面这个具体的例子。

<Project Sdk="Microsoft.NET.Sdk.Web">  <PropertyGroup>    <TargetFramework>netcoreapp2.1</TargetFramework>  </PropertyGroup>  <ItemGroup>    <Protobuf Include="Protos\userinfo.proto" GrpcServices="Server" />  </ItemGroup>    <ItemGroup>    <PackageReference Include="Microsoft.AspNetCore.App" />    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />    <PackageReference Include="Google.Protobuf" Version="3.8.0" />    <PackageReference Include="Grpc.Core" Version="1.22.0" />    <PackageReference Include="Grpc.Tools" Version="1.22.0">      <PrivateAssets>all</PrivateAssets>      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>    </PackageReference>  </ItemGroup></Project>

再往下,就是写代码了。

服务端代码编写

服务端代码分两部分,一部分是服务具体的实现,一部分是服务怎么起来。

先来看看服务的具体实现。

namespace MyBasedServiceA{    using Grpc.Core;    using System.Linq;    using System.Threading.Tasks;    public class UserInfoServiceImpl : UserInfoService.UserInfoServiceBase    {        public override Task<GetUserByIdRelpy> GetById(GetUserByIdRequest request, ServerCallContext context)        {            var result = new GetUserByIdRelpy();            var user = FakeUserInfoDb.GetById(request.Id);            result.Id = user.Id;            result.Name = user.Name;            result.Age = user.Age;            result.CreateTime = user.CreateTime;            return Task.FromResult(result);        }        public override Task<GetUserListReply> GetList(GetUserListRequest request, ServerCallContext context)        {            var result = new GetUserListReply();            var userList = FakeUserInfoDb.GetList(request.Id, request.Name);            result.Code = 0;            result.Msg = "成功";            result.Data.AddRange(userList.Select(x => new GetUserListReply.Types.MsgItem            {                Id = x.Id,                Age = x.Age,                CreateTime = x.CreateTime,                Name = x.Name            }));            return Task.FromResult(result);        }        public override Task<SaveUserReply> Save(SaveUserRequest request, ServerCallContext context)        {            var result = new SaveUserReply();            var flag = FakeUserInfoDb.Save(request.Name, request.Age);            result.Code = 0;            result.Msg = "成功";                        return Task.FromResult(result);        }    }}

可以看到上面的代码,我们只要继承由proto文件生成的一个基类,然后去重写它的实现,就可以认为是实现了一个服务。这个其实就是写我们具体的业务逻辑,大boss有什么需求,堆上去就好了。

然后来看第二部分,服务怎么起来。

在这里我选择的方案是使用通用主机来跑。当然也可以直接在Startup的Configure方法中去启动服务。只要能起来就行 :-D

namespace MyBasedServiceA{    using Grpc.Core;    using Microsoft.Extensions.Hosting;    using Microsoft.Extensions.Logging;    using System;    using System.Threading;    using System.Threading.Tasks;    public class MyBasedServiceAHostedService : BackgroundService    {        private readonly ILogger _logger;        private Server _server;        public MyBasedServiceAHostedService(ILoggerFactory loggerFactory)        {            this._logger = loggerFactory.CreateLogger<MyBasedServiceAHostedService>();            _server = new Server            {                Services = { UserInfoService.BindService(new UserInfoServiceImpl()) },                                                Ports = { new ServerPort("0.0.0.0", 9999, ServerCredentials.Insecure) }            };        }              protected override Task ExecuteAsync(CancellationToken stoppingToken)        {            _server.Start();            return Task.CompletedTask;        }    }}

然后是Program中的代码。

namespace MyBasedServiceA{    using Microsoft.AspNetCore.Builder;    using Microsoft.AspNetCore.Hosting;    using Microsoft.Extensions.DependencyInjection;    using Microsoft.Extensions.Hosting;    using Microsoft.Extensions.Logging;    public class Program    {        public static void Main(string[] args)        {            var host = new HostBuilder()                .ConfigureLogging((hostContext, configLogging) =>                {                    configLogging.AddConsole();                    configLogging.AddDebug();                })                .ConfigureServices((hostContext, services) =>                {                    services.AddHostedService<MyBasedServiceAHostedService>();                })                .Build();            host.Run();        }    }}

到这里,服务端已经可以了。

下面就是客户端了。

客户端代码编写

在这里客户端,我们写两个,一个基于C#(.net core), 一个基于python。刚好也验证一下gRPC的多语言。

正常来说,我们所说的客户端可能很大一部分是对外的WEB API了,就是说api的内部实现,是rpc的调用,而对外的是常见的返回JSON的rest api。

我们先通过控制台来体验一下它的客户端调用。

C#(.net core)客户端

class Program{    static void Main(string[] args)    {        var channel = new Channel("localhost:9999", ChannelCredentials.Insecure);        var client = new UserInfoService.UserInfoServiceClient(channel);        var saveResponse = client.Save(new SaveUserRequest { Age = 99, Name = "c#name" });        Console.WriteLine($"Save received: code = {saveResponse.Code} ,  msg = {saveResponse.Msg}");        var getListResponse = client.GetList(new GetUserListRequest { });        Console.WriteLine($"GetList received: code =  {getListResponse.Code} ,  msg = {getListResponse.Msg}");        foreach (var item in getListResponse.Data)        {            Console.WriteLine(item.Name);        }        Console.ReadKey();    }}

其实这种方式我们很容易联想到WCF,都是生成代码,可以直接点出来的方法,强类型的使用体验。不过我是基本没有用过WCF的,貌似暴露了年龄了,逃~~

python客户端

python要想运行gRPC相关的,要先安装 grpcio-tools,然后再用命令生成相应的文件。

# 安装pip install grpcio-tools# 生成python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./userinfo.proto

具体实现

import grpcimport userinfo_pb2, userinfo_pb2_grpc_HOST = 'localhost'_PORT = '9999'def run():    conn = grpc.insecure_channel(_HOST + ':' + _PORT)    client = userinfo_pb2_grpc.UserInfoServiceStub(channel=conn)    saveResponse = client.Save(userinfo_pb2.SaveUserRequest(name="pyname", age=39))    print("Save received: code = " + str(saveResponse.code) + ", msg = "+ saveResponse.msg)    getListResponse = client.GetList(userinfo_pb2.GetUserListRequest())    print("GetList received: code = " + str(getListResponse.code) + ", msg = "+ getListResponse.msg)    for d in getListResponse.data:        print(d.name)if __name__ == '__main__':    run()

同样也是很简洁。

运行效果

在服务端起来的情况下,先运行.net core的客户端,然后再运行python的客户端,结果大致如下。

640?wx_fmt=png

注: 在调用的时候,有几个概念要知道!!

  1. gRPC中没有采用传统的timeout方式去处理,而是采用了Deadline机制,觉得这个机制和我们的CancellationToken很相似

  2. 无论是客户端还是服务端,都可以随时取消RPC

可以看到我们现在的地址都是硬编码的,因为只有一个节点,然后在线上环境,都会是多节点的,所以我们需要有服务注册和服务发现,下面我们就结合consul来完成服务注册与发现。

服务治理(注册与发现)

当然现在可选的工具还是有很多的,consul,etcd,eureka等,当然最好的还是直接上K8S,不过我们公司还有很长的一段路才能上,所以我们就怎么简单怎么来了。

下面我们调整一下服务端的代码,让gRPC的服务可以注册到consul上面。

public class MyBasedServiceAHostedService : BackgroundService{    private readonly Microsoft.Extensions.Logging.ILogger _logger;    private readonly IConfiguration _configuration;    private readonly IConsulClient _consulClient;    private Server _server;    private AgentServiceRegistration registration;    public MyBasedServiceAHostedService(ILoggerFactory loggerFactory, IConfiguration configuration, IConsulClient consulClient, IHostingEnvironment environment)    {        this._logger = loggerFactory.CreateLogger<MyBasedServiceAHostedService>();        this._configuration = configuration;        this._consulClient = consulClient;        var port = _configuration.GetValue<int>("AppSettings:Port");        _logger.LogInformation($"{environment.EnvironmentName} Current Port is : {port}");                GrpcEnvironment.SetLogger(new GrpcAdapterLogger(loggerFactory));        var address = GetLocalIP();        _logger.LogInformation($"{environment.EnvironmentName} Current IP is : {address}");        registration = new AgentServiceRegistration()        {            ID = $"MyBasedServiceA-{Guid.NewGuid().ToString("N")}",            Name = "MyBasedServiceA",            Address = address,            Port = port,            Check = new AgentServiceCheck            {                TCP = $"{address}:{port}",                DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),                Interval = TimeSpan.FromSeconds(10),                Timeout = TimeSpan.FromSeconds(5)            }        };        _server = new Server        {            Ports = { new ServerPort("0.0.0.0", port, ServerCredentials.Insecure) }        };                if (!environment.IsProduction())        {            _server.Services.Add(UserInfoService.BindService(new UserInfoServiceImpl()).Intercept(new AccessLogInterceptor(loggerFactory)));        }        else        {            _server.Services.Add(UserInfoService.BindService(new UserInfoServiceImpl()));        }    }      protected override async Task ExecuteAsync(CancellationToken stoppingToken)    {        await _consulClient.Agent.ServiceDeregister(registration.ID);        await _consulClient.Agent.ServiceRegister(registration);        _logger.LogInformation($"Registering with Consul {registration.ID} OK");        _server.Start();    }    public override async Task StopAsync(CancellationToken cancellationToken)    {        _logger.LogInformation("Unregistering from Consul");        await _consulClient.Agent.ServiceDeregister(registration.ID);        await _server.KillAsync();        await base.StopAsync(cancellationToken);    }    private string GetLocalIP()    {        try        {            string hostName = Dns.GetHostName();            IPHostEntry ipEntry = Dns.GetHostEntry(hostName);            for (int i = 0; i < ipEntry.AddressList.Length; i++)            {                if (ipEntry.AddressList[i].AddressFamily == AddressFamily.InterNetwork)                {                    return ipEntry.AddressList[i].ToString();                }            }            return "127.0.0.1";        }        catch (Exception ex)        {            _logger.LogError(ex, "Get Local Ip error");            return "127.0.0.1";        }    }}

然后客户端要定义一个从consul中读取实例的方法。

从consul中读取对应service的健康实例,正常会用定时轮训的方式去读取,或者写入短时间的缓存。

public class FindService : IFindService {    private readonly ILogger _logger;    private readonly IConsulClient _consulClient;    private readonly ConcurrentDictionary<string, (List<string> List, DateTimeOffset Expiration)> _dict;    public FindService(ILoggerFactory loggerFactory, IConsulClient consulClient)    {        _logger = loggerFactory.CreateLogger<FindService>();        _consulClient = consulClient;        _dict = new ConcurrentDictionary<string, (List<string> List, DateTimeOffset Expiration)>();    }    public async Task<string> FindServiceAsync(string serviceName)    {        var key = $"SD:{serviceName}";        if (_dict.TryGetValue(key, out var item) && item.Expiration > DateTimeOffset.UtcNow)        {            _logger.LogInformation($"Read from cache");            return item.List[new Random().Next(0, item.List.Count)];        }        else        {            var queryResult = await _consulClient.Health.Service(serviceName, string.Empty, true);            var result = new List<string>();            foreach (var serviceEntry in queryResult.Response)            {                result.Add(serviceEntry.Service.Address + ":" + serviceEntry.Service.Port);            }            _logger.LogInformation($"Read from consul : {string.Join(",", result)}");            if (result != null && result.Any())            {                                var val = (result, DateTimeOffset.UtcNow.AddSeconds(600));                _dict.AddOrUpdate(key, val, (x, y) => val);                var count = result.Count;                return result[new Random().Next(0, count)];            }            return "";        }    }}

调用的时候。

private async Task<(UserInfoService.UserInfoServiceClient Client, string Msg)> GetClientAsync(string name){    var target = await _findService.FindServiceAsync(name);    _logger.LogInformation($"Current target = {target}");    if (string.IsNullOrWhiteSpace(target))    {        return (null, "can not find a service");    }    else    {        var channel = new Channel(target, ChannelCredentials.Insecure);        var client = new UserInfoService.UserInfoServiceClient(channel);        return (client, string.Empty);    }}

然后我们编写docker-compose.yml, 让它在docker中跑

version: '3.4'services:  xxxserver1:    image: ${DOCKER_REGISTRY-}xxxserver    build:      context: .      dockerfile: MyBasedServiceA/Dockerfile    ports:      - "9999:9999"      depends_on:      - consuldev          networks:        backend:  xxxserver2:    image: ${DOCKER_REGISTRY-}xxxserver    build:      context: .      dockerfile: MyBasedServiceA/Dockerfile    ports:      - "9995:9999"       depends_on:      - consuldev          networks:        backend:              xxxclient:    image: ${DOCKER_REGISTRY-}xxxclient    build:      context: .      dockerfile: XXXService/Dockerfile    ports:      - "9000:80"    depends_on:      - consuldev          - xxxserver1      - xxxserver2    networks:        backend:  consuldev:    image: consul:latest        ports:      - "8300:8300"      - "8400:8400"      - "8500:8500"        networks:        backend:networks:    backend:          driver: bridge

运行结果如下:

当用 docker 把这几个服务都跑起来之后, 可以看到类似下面的输出

640?wx_fmt=png

也可以用docker ps命令来看一下那几个服务是不是真的在运行。

640?wx_fmt=png

同时,我们打开consul的UI界面,可以看到我们服务端的两个实例已经注册上来了。

640?wx_fmt=png

当我们用客户端去访问的时候,可以发现,第一次它是从consul中取下来了两个ip,然后随机选了一个进行访问。

640?wx_fmt=png

我们把其中一个服务端(0.4)stop,用来模拟某个节点出现异常,被剔除的情况 ,可以发现consul上面已经看不到了,只剩下0.3这个节点了。

640?wx_fmt=png

如果我们的调度策略没有及时将"死掉"的节点剔除,就会出现下面的这种情况。

640?wx_fmt=png

最后,把stop的服务端启动,模拟恢复正常,这个时候可以发现无论调度到那个节点都可以正常访问了。

640?wx_fmt=png

.NET Core 2.x 和 .NET Core 3.0的细微区别

在.NET Core 2.x中,我们的Server,是需要手动控制的,在.NET Core 3.0中,可以认为它已经和Kestrel融为一体了,不再需要我们再手动去Start了。

同样的,服务的实现,也和Endpoint Routing紧密的结合在一起了,不再和之前一样了。

可以看看下面的例子,可能会发现,这是一种熟悉的不能再熟悉的感觉。

public class Startup{    public void ConfigureServices(IServiceCollection services)    {        services.AddGrpc(x=>        {            x.EnableDetailedErrors = true;            x.Interceptors.Add<AccessLogInterceptor>();        });    }      public void Configure(IApplicationBuilder app, IWebHostEnvironment env)    {        if (env.IsDevelopment())        {            app.UseDeveloperExceptionPage();        }        app.UseRouting();        app.UseEndpoints(endpoints =>        {            endpoints.MapGrpcService<GreeterService>();            endpoints.MapGrpcService<UserService>();            endpoints.MapGet("/", async context =>            {                await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");            });        });    }}

客户端的用法也和以前不一样了,直接看一个例子就很清晰了。

 
 

从它的用法上,我们也有熟悉的面孔 HttpClient

虽然gRPC是基于HTTP/2的,但是可以看到我们上面小节的例子中,还是能够指定不使用的。然而到.NET Core 3.0之后,我们就必须要使用https了,不然客户端就是调不通的。同样的,我们也可以在grpc-dotnet的仓库上面看到,如果想不使用HTTP/2,就让我们用回之前的老库,不要用新库,James Newton-King就是这么直接。

https://github.com/grpc/grpc-dotnet/issues/277

https://github.com/grpc/grpc-dotnet/issues/405

https://github.com/grpc/grpc-dotnet/issues/431

扩展阅读

  • 思考gRPC :为什么是HTTP/2

  • Grpc中Deadline分析

  • protocol-buffers官网

  • Using CancellationTokens in ASP.NET Core MVC controllers

文中出现的示例代码都可以在下面这个仓库找到

catcherwong-archive/2019

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在Java中使用gRPC进行简单使用包括以下几个步骤: 1. 定义.proto文件:首先,你需要定义一个.proto文件,用于描述你的服务和消息格式。这个文件可以使用Protocol Buffers语言编写。例如,你可以定义一个简单的服务和消息格式如下: ```protobuf syntax = "proto3"; package com.example; service GreetingService { rpc SayHello(HelloRequest) returns (HelloResponse); } message HelloRequest { string name = 1; } message HelloResponse { string message = 1; } ``` 2. 生成Java代码:一旦你定义了.proto文件,你可以使用gRPC的编译器将其生成为Java代码。你可以使用以下命令生成代码: ```bash protoc --java_out=<output_directory> <proto_file>.proto ``` 这将生成与.proto文件相匹配的Java类。 3. 实现服务逻辑:在服务器端,你需要编写一个类来实现你定义的服务接口。例如,对于上面的例子,你可以编写一个GreetingServiceImpl类来实现GreetingService接口: ```java package com.example; import io.grpc.stub.StreamObserver; public class GreetingServiceImpl extends GreetingServiceGrpc.GreetingServiceImplBase { @Override public void sayHello(HelloRequest request, StreamObserver<HelloResponse> responseObserver) { String greeting = "Hello, " + request.getName() + "!"; HelloResponse response = HelloResponse.newBuilder().setMessage(greeting).build(); responseObserver.onNext(response); responseObserver.onCompleted(); } } ``` 4. 启动服务器:你需要编写一个服务器类来启动gRPC服务器并注册你的服务实现类。例如: ```java package com.example; import io.grpc.Server; import io.grpc.ServerBuilder; import java.io.IOException; public class ServerMain { public static void main(String[] args) throws IOException, InterruptedException { Server server = ServerBuilder.forPort(50051) .addService(new GreetingServiceImpl()) .build(); server.start(); System.out.println("Server started"); server.awaitTermination(); } } ``` 5. 创建客户端:最后,你可以编写一个客户端类来调用你的服务。例如: ```java package com.example; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; public class ClientMain { public static void main(String[] args) { ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051) .usePlaintext() .build(); GreetingServiceGrpc.GreetingServiceBlockingStub stub = GreetingServiceGrpc.newBlockingStub(channel); HelloRequest request = HelloRequest.newBuilder().setName("Alice").build(); HelloResponse response = stub.sayHello(request); System.out.println(response.getMessage()); channel.shutdown(); } } ``` 这样,你就可以使用gRPC进行简单通信了。在实际使用中,你可能还需要处理错误、流式传输等更复杂的场景。你可以参考gRPC的官方文档和示例来深入了解更多用法和细节。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值