从0到1手写分布式对象存储系统-02分布式架构初建

架构

(架构图源于参考书籍)

RabbitMQ包封装

下载

go get "github.com/streadway/amqp"

封装

package rabbitmq

import (
	"encoding/json"
	"log"

	"github.com/streadway/amqp"
)

/*
对 RabbitMQ 函数库的再次封装,便于操作
*/

type RabbitMQ struct {
	channel  *amqp.Channel
	Name     string
	exchange string
}

/*创建新结构体*/
func New(s string) *RabbitMQ {
	conn, e := amqp.Dial(s)
	if e != nil {
		log.Fatalln(e)
	}

	ch, e := conn.Channel()
	if e != nil {
		log.Fatalln(e)
	}

	q, e := ch.QueueDeclare(
		"",    // 名字
		false, // 耐用
		true,  // 不使用时删除
		false, // 专用的
		false, // 不等待
		nil,   // 参数
	)

	if e != nil {
		log.Fatalln(e)
	}

	// 分配内存,构建一个新结构体
	mq := new(RabbitMQ)
	mq.channel = ch
	mq.Name = q.Name

	// 返回对象指针
	return mq
}

/*将 RabbitMQMQ 结构体的消息队列与一个 exchange 绑定*/
func (q *RabbitMQ) Bind(exchange string) {
	e := q.channel.QueueBind(q.Name, "", exchange, false, nil)
	if e != nil {
		log.Fatalln(e)
	}
	q.exchange = exchange
}

/*往某个消息队列发送消息*/
func (q *RabbitMQ) Send(queue string, body interface{}) {
	// 序列化
	str, e := json.Marshal(body)
	if e != nil {
		log.Fatalln(e)
	}
	e = q.channel.Publish("", queue, false, false, amqp.Publishing{
		ReplyTo: q.Name,
		Body:    []byte(str),
	})
	if e != nil {
		log.Fatalln(e)
	}
}

/*往某个 exchange 发送消息*/
func (q *RabbitMQ) Publish(exchange string, body interface{}) {
	// 序列化
	str, e := json.Marshal(body)
	if e != nil {
		log.Fatalln(e)
	}
	e = q.channel.Publish(exchange, "", false, false, amqp.Publishing{
		ReplyTo: q.Name,
		Body:    []byte(str),
	})
	if e != nil {
		log.Fatalln(e)
	}
}

/*生成一个接收信息的管道*/
func (q *RabbitMQ) Consume() <-chan amqp.Delivery {
	c, e := q.channel.Consume(q.Name, "", true, false, false, false, nil)
	if e != nil {
		log.Fatalln(e)
	}
	return c
}

/*关闭消息队列*/
func (q *RabbitMQ) Close() {
	q.channel.Close()
}

数据服务实现

心跳机制

定时向 apiServers exchange 发送自身服务节点的监听地址,以告知自身存在。

package heartbeat

import (
	"demo/rabbitmq"
	"demo/sys"
	"os"
	"time"
)

/*每 3s 向 apiServers exchange 发送一次心跳,心跳信息为该节点的监听地址*/
func StartHeartbeat()  {
	q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
	defer q.Close()
	for {
		q.Publish(sys.ApiServersExchange,os.Getenv(sys.ListenAddress))
		// 休眠 3s
		time.Sleep(3 * time.Second)
	}
}

存储对象定位

监听 dataServers exchange 的定位信息, 接收定位请求时,如果存储对象存在,则回送本节点监听地址,已告知存储对象在该节点。

package locate

import (
	"demo/rabbitmq"
	"demo/sys"
	"log"
	"os"
	"strconv"
)

// 定位对象
func Locate(name string) bool {
	// 访问磁盘上对应的文件名
	_,e := os.Stat(name)
	// 判读文件名是否存在
	return !os.IsNotExist(e)
}

// 监听定位信息
func StartLocate(){
	q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
	defer q.Close()
	// 绑定 data 网络层
	q.Bind(sys.DataServersExchange)
	// 获取信息管道
	c := q.Consume()
	// 从管道中遍历信息,msg 为需要定位的存储对象名字
	for msg := range c{
		// 去掉 json 序列化的双引号
		obj,e := strconv.Unquote(string(msg.Body))
		if e != nil {
			log.Fatalln(e)
		}
		// 存储根目录拼接文件名,定位存储对象
		if Locate(os.Getenv(sys.StorageRoot) + obj){
			// 如果存储对象存在,则回送本节点监听地址,已告知存储对象在该节点
			q.Send(msg.ReplyTo,os.Getenv(sys.ListenAddress))
		}
	}
}

PUT 和 GET 处理函数

PUT 请求时创建或者替换一个资源,GET 请求时获取资源,将这两个业务封装在 Handler 函数。

package objects

import (
	"demo/sys"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
)

/*
PUT/GET 业务处理函数
*/
func Handler(w http.ResponseWriter, r *http.Request) {
	m := r.Method

	// PUT 方法时,创建或者替换资源
	if m == http.MethodPut {
		put(w, r)
		return
	}

	// GET 方法时,获取资源
	if m == http.MethodGet {
		get(w, r)
		return
	}

	// 其他方式时,返回状态码,方法不允许
	w.WriteHeader(http.StatusMethodNotAllowed)
}

func put(w http.ResponseWriter, r *http.Request) {

	// 处理文件路径,将文件保存在 D:/uploadFile 目录下,文件名为客户端 PUT 请求时的名字,文件名应为转义后的名字
	// 文件路径可以使用 os.Getenv(var) 通过环境变量来指定会更加灵活
	log.Println(r.URL.EscapedPath())
	fileName := os.Getenv(sys.StorageRoot) + strings.Split(r.URL.EscapedPath(), "/")[2]
	// 创建文件
	f, e := os.Create(fileName)
	if e != nil {
		log.Println(e)
		// 返回服务器错误状态码
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	// 将客户端 PUT 请求的文件数据保存到服务端创建的文件 f 中
	io.Copy(f, r.Body)
	// 关闭文件
	defer f.Close()
}

func get(w http.ResponseWriter, r *http.Request) {
	// 处理文件路径,从 D:/uploadFile 目录下获取文件,文件名为客户端 GET 请求时的名字,文件名应为转义后的名字
	// 文件路径可以使用 os.Getenv(var) 通过环境变量来指定会更加灵活
	log.Println(r.URL.EscapedPath())
	fileName := os.Getenv(sys.StorageRoot) + strings.Split(r.URL.EscapedPath(), "/")[2]
	// 创建文件
	f, e := os.Open(fileName)
	if e != nil {
		log.Println(e)
		// 返回文件未找到状态码
		w.WriteHeader(http.StatusNotFound)
		return
	}
	// 将服务端的文件 f 复制到响应体 w 中
	io.Copy(w, f)
	// 关闭文件
	defer f.Close()
}

/*
注意:
f 本身类型是 *os.File,它是一个指向 os.File 结构体的指针,而 os.File 结构体同时实现了 io.Writer 和 io.Reader 两个接口,
因此它既是一个 io.Writer 又是 一个 io.Reader,可以在 io.Copy 中作为不同的参数使用。
*/

main 函数

package main

import (
	"demo/dataServer/heartbeat"
	"demo/dataServer/locate"
	"demo/dataServer/objects"
	"demo/sys"
	"net/http"
	"os"
)

func main() {
	// 向 apiServers exchange 发送心跳
	go heartbeat.StartHeartbeat()
	// 监听定位信息
	go locate.StartLocate()
	// 注册URL与逻辑处理函数
	http.HandleFunc("/handleObjs/", objects.Handler)
	// 启动并监听服务
	http.ListenAndServe(os.Getenv(sys.ListenAddress), nil)
}

至此,我们实现了数据服务层的基本业务:

1、数据服务节点向 apiServers exchange 发送心跳信息以告知自身存在。

2、数据服务节点监听 dataServers exchange 定位信息,若定位到存储对象信息则单向回送自身节点信息以告知 dataServers exchange 所需要的存储对象在该节点。

3、数据服务节点处理 PUT 和 GET 请求实现基础的存储对象创建和获取。

接口服务实现

监听心跳

监听数据服务节点的心跳信息,缓存全部的数据节点。

package heartbeat

import (
	"demo/rabbitmq"
	"demo/sys"
	"log"
	"math/rand"
	"os"
	"strconv"
	"sync"
	"time"
)

var dataServers = make(map[string]time.Time) // 缓存数据服务节点
var m sync.RWMutex // 使用多读写锁,比互斥锁高效

/*监听心跳*/
func ListenHeartbeat() {
	q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
	defer q.Close()
	// 绑定 api 网络层
	q.Bind(sys.ApiServersExchange)
	c := q.Consume()
	// 移除过期节点
	go removeExpiredDataServer()
	// 监听数据服务节点心跳,将心跳信息写入全局缓存
	for msg := range c {
		dataServer, e := strconv.Unquote(string(msg.Body))
		if e != nil {
			log.Fatalln(e)
		}
		// 写操作互斥,防止多 goroutine 对 dataServers 同时写
		m.Lock()
		dataServers[dataServer] = time.Now()
		m.Unlock()
	}
}

/*移除过期的数据服务节点*/
func removeExpiredDataServer() {
	// 每 3s 扫描一遍缓存的数据服务节点
	// 若当前时间减去心跳时间超过 6s 则判定为节点过期
	for {
		time.Sleep(3 * time.Second)
		// 写操作互斥,防止多 goroutine 对 dataServers 同时写
		m.Lock()
		for s, t := range dataServers {
			if t.Add(6 * time.Second).Before(time.Now()) {
				delete(dataServers, s)
			}
		}
		m.Unlock()
	}
}

/*获取全部数据服务节点*/
func GetDataServers() []string{
	// 读锁,可多 goroutine 对 dataServers 同时读
	m.RLock()
	defer m.RUnlock()
	dataServer := make([]string,0)
	for s,_ := range dataServers {
		dataServer = append(dataServer,s)
	}
	return dataServer
}

/*随机选择一个数据服务节点返回*/
func ChooseRandomDataServer() string{
	dataServer := GetDataServers()
	length := len(dataServer)
	if length == 0 {
		return ""
	}
	return dataServer[rand.Intn(length)]
}

定位存储对象

package locate

import (
	"demo/rabbitmq"
	"demo/sys"
	"encoding/json"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

/*定位存储对象*/
func Locate(name string) string{
	// 创建临时消息队列
	q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
	// 向 data 网络层群发这个存储对象的名字
	q.Publish(sys.DataServersExchange,name)
	// 获取信息管道
	c := q.Consume()
	// 休眠一秒之后将临时消息队列关闭,防止超时阻塞
	go func() {
		time.Sleep(time.Second)
		q.Close()
	}()
	// 从管道中读取定位信息
	msg := <- c
	s,_ := strconv.Unquote(string(msg.Body))
	return s
}

/*判断存储对象是否存在*/
func Exist(name string) bool {
	return Locate(name) != ""
}

/*处理定位请求*/
func Handler(w http.ResponseWriter,r *http.Request){
	m := r.Method
	// 非 GET 方法
	if m!= http.MethodGet{
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	objInfo := Locate(strings.Split(r.URL.EscapedPath(),"/")[2])
	// 未找到该存储对象
	if len(objInfo) == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
	obj,_ := json.Marshal(objInfo)
	w.Write(obj)
}

PUT 和 GET 处理函数

接口服务的 PUT 和 GET 请求是将 HTTP 请求转发到数据服务,实际上是调用数据服务的 PUT 和 GET 方法。

package objects

import (
	"demo/apiServer/heartbeat"
	"demo/apiServer/locate"
	"demo/apiServer/objectStream"
	"demo/sys"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
)

/*接口服务的 PUT 和 GET 请求是将 HTTP 请求转发到数据服务,实际上是调用数据服务的 PUT 和 GET 方法*/
func Handler(w http.ResponseWriter, r *http.Request) {
	m := r.Method

	// PUT 方法时,创建或者替换资源
	if m == http.MethodPut {
		put(w, r)
		return
	}

	// GET 方法时,获取资源
	if m == http.MethodGet {
		get(w, r)
		return
	}

	// 其他方式时,返回状态码,方法不允许
	w.WriteHeader(http.StatusMethodNotAllowed)
}

/*处理接口服务 PUT 请求*/
func put(w http.ResponseWriter,r *http.Request){
	// 获取存储对象名字
	obj := strings.Split(r.URL.EscapedPath(),"/")[2]
	// 存储请求数据体
	httpStatus,e := storeObject(r.Body,obj)
	if e != nil {
		log.Fatalln(e)
	}
	// 返回存储结果
	w.WriteHeader(httpStatus)
}

func storeObject(r io.Reader,obj string)(int,error){
	// 获取接口服务节点存储对象的流
	stream, e := putStream(obj)
	if e != nil {
		return http.StatusServiceUnavailable,e
	}
	// 将请求数据体拷贝到流 stream
	io.Copy(stream,r)
	// 关闭流
	e = stream.Close()
	if e != nil {
		return http.StatusInternalServerError,e
	}
	// 返回成功状态码
	return http.StatusOK,nil
}

func putStream(obj string)(*objectStream.PutStream,error){
	// 随机选择一个数据服务节点
	server := heartbeat.ChooseRandomDataServer()
	// 若没有可用的数据服务节点则返回错误信息
	if server == "" {
		return nil,fmt.Errorf(sys.DataServerNotFound)
	}
	// 返回数据服务节点存储对象的流
	return objectStream.NewPutStream(server,obj),nil
}

/*处理接口服务 GET 请求*/
func get(w http.ResponseWriter,r *http.Request){
	// 获取存储对象名称
	obj := strings.Split(r.URL.EscapedPath(),"/")[2]
	// 获取存储对象数据流
	stream, e := getStream(obj)
	if e != nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}
	// 将数据流拷贝到响应流 w
	io.Copy(w,stream)
}

func getStream(obj string) (io.Reader,error){
	// 根据存储对象名称进行定位
	server := locate.Locate(obj)
	// 未找到该存储对象时返回定位失败错误
	if server == "" {
		return nil,fmt.Errorf(sys.DataServerLocateFail,obj)
	}
	// 定位到存储对象时,返回该对象的数据流
	return objectStream.NewGetStream(server,obj)
}


处理 PUT 和 GET 请求时的读写流

package objectStream

import (
	"demo/sys"
	"fmt"
	"io"
	"net/http"
)

type PutStream struct {
	writer *io.PipeWriter // writer 用于实现 Write 方法
	c      chan error // c 用于将 goroutine 传输数据过程中抛出的错误传回主协程
}

/* 构建接口服务存储对象流,当数据通过写入到该 PutStream.writer 时,向数据服务节点请求的 reader 就会读到管道中的数据 */
func NewPutStream(server, obj string) *PutStream {
	// 创建一对管道互连的 reader 和 writer,写入 writer 中的数据可以从 reader 中读出来
	reader, writer := io.Pipe()
	c := make(chan error)

	// 由于管道读写是阻塞的,因此需要另起一个 goroutine 来向数据服务节点发起 PUT 请求,此时接口服务是一个 client 端
	go func() {
		// 构建请求
		request, _ := http.NewRequest(http.MethodPut, "http://"+server+"/handleObjs/"+obj, reader)
		client := http.Client{}
		// 发起请求
		r, e := client.Do(request)
		// 其他异常
		if e == nil && r.StatusCode != http.StatusOK {
			e = fmt.Errorf(sys.DataServerError, r.StatusCode)
		}
		// 将异常写入管道
		c <- e
	}()

	// 返回接口服务存储对象流
	return &PutStream{
		writer: writer,
		c:      c,
	}
}

/*实现 io.Writer 接口*/
func (w *PutStream) Write(p []byte) (n int, err error) {
	return w.writer.Write(p)
}

/*让管道另一端的 reader 读到 io.EOF ,否则 client.Do(request) 将一直处于请求阻塞状态*/
func (w *PutStream) Close() error {
	w.writer.Close()
	return <-w.c
}

type GetStream struct {
	reader io.Reader
}

func newGetStream(url string) (*GetStream, error) {
	// 向数据服务节点发起 GET 请求
	r, e := http.Get(url)
	if e != nil {
		return nil, e
	}
	if r.StatusCode != http.StatusOK {
		return nil, fmt.Errorf(sys.DataServerError, r.StatusCode)
	}
	// 返回请求响应数据
	return &GetStream{
		reader: r.Body,
	}, nil
}

func NewGetStream(server, obj string) (*GetStream, error) {
	// 当数据服务节点为空或者请求对象名字为空,则抛出异常
	if server == "" || obj == "" {
		return nil, fmt.Errorf(sys.InvalidServerOrObject, server, obj)
	}
	// 调用数据服务节点 GET 接口,返回请求响应数据
	return newGetStream("http://" + server + "/handleObjs/" + obj)
}

/*实现 io.Reader 接口*/
func (r *GetStream)Read(p []byte) (n int, err error) {
	return r.reader.Read(p)
}

main 函数

package main

import (
	"demo/apiServer/heartbeat"
	"demo/apiServer/locate"
	"demo/apiServer/objects"
	"demo/sys"
	"net/http"
	"os"
)

func main(){
	// 监听数据服务节点心跳
	go heartbeat.ListenHeartbeat()
	// 处理对象请求,实际上是将对象请求转发给数据服务
	http.HandleFunc("/handleObjs/",objects.Handler)
	// 处理定位请求
	http.HandleFunc("/locateObj/",locate.Handler)
	// 启动并监听服务
	http.ListenAndServe(os.Getenv(sys.ListenAddress),nil)
}

至此,我们实现了接口服务层的基本业务:

1、接口服务监听 apiServers exchange 以获取数据服务节点心跳信息,并缓存数据服务节点。

2、接口服务定位存储对象时会向 dataServers exchange 群发信息,等待数据服务节点响应,否则报 404,不会一直等待阻塞。

搭建 RabbitMQ 服务

# 添加仓库
curl -s https://packagecloud.io/install/repositories/rabbitmq/erlang/script.rpm.sh | sudo bash

# 安装 erlang
dnf install erlang

# 下载 rabbitmq3.9.7
wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.9.7/rabbitmq-server-3.9.7-1.el8.noarch.rpm

# 添加 socat 依赖
yum install socat

# 安装 rabbitmq 
rpm -ivh rabbitmq-server-3.9.7-1.el8.noarch.rpm

# 安装 web 管理插件
rabbitmq-plugins enable rabbitmq_management

# 启动 rabbitmq-server
systemctl start rabbitmq-server.service

# 创建远程用户,账号 yushanma ,密码 passwd
rabbitmqctl add_user yushanma passwd

# 设置该用户为 administrator 角色
rabbitmqctl set_user_tags yushanma administrator

# 设置权限
rabbitmqctl set_permissions -p '/' yushanma '.' '.' '.'

# 重启 rabbitmq 服务
systemctl restart rabbitmq-server.service

# 防火墙放行端口
iptables -I INPUT 4 -p tcp -m state --state NEW -m tcp --dport 15672 -j ACCEPT

# 保存防火墙规则
service iptables save

远程访问:http://ip:15672/ 账号密码为刚刚设定的 yushanma passwd

打开  http://ip:15672/cli ,下载 rabbitmqadmin ,也可以直接通过 uri 下载。

# 下载 rabbitmqadmin
wget http://node1:15672/cli/rabbitmqadmin

# 移到 PATH 目录,并赋予权限
mv rabbitmqadmin /usr/local/bin
chmod 777 /usr/local/bin/rabbitmqadmin

# 查看 rabbitmqadmin 命令
rabbitmqadmin -help

# 若无 python3 环境则先安装 python
dnf update -y
dnf install python3 -y
whereis python3
alternatives --set python /usr/bin/python3
python --version

测试

模拟分布式架构环境

# 查看本机网络接口
ip a

# 数据服务节点 eth0:1~6
# IP范围 172.16.17.1 ~ 172.16.17.6

# 接口服务节点 eth0:7~8
# IP范围 172.16.18.1 ~ 172.16.18.2

# 网络接口绑定多个 IP
ifconfig eth0:1 172.16.17.1/20
ifconfig eth0:2 172.16.17.2/20
ifconfig eth0:3 172.16.17.3/20
ifconfig eth0:4 172.16.17.4/20
ifconfig eth0:5 172.16.17.5/20
ifconfig eth0:6 172.16.17.6/20
ifconfig eth0:7 172.16.18.1/20
ifconfig eth0:8 172.16.18.2/20

eth0 为本机的内网网络接口,我们只需要用 ifconfig 接口别名 IP/掩码位数 就可以在同一接口上绑定多个 IP 。

# 定义一个Fanout路由,Fanout—发布与订阅模式,是一种广播机制,它是没有路由key的模式。 生产者将消息通过交换机存进队列 而后 消费者通过队列去消费。
# rabbitmqadmin declare exchange name=my.fanout type=fanout

# 创建 dataServers exchange
rabbitmqadmin declare exchange name=dataServers type=fanout

# 创建 apiServers exchange
rabbitmqadmin declare exchange name=apiServers type=fanout

在 /home/sam/ 路径下创建存储目录:

for i in `seq 1 6`; do mkdir -p files/$i/objects; done

导入 rabbitmq-server 环境变量:

export RABBITMQ_SERVER=amqp://yushanma:passwd@172.16.16.4:5672
# 15672 是管理页面端口,5672 是服务端口

启动服务:

# 启动数据服务节点
LISTEN_ADDRESS=172.16.17.1:12345 STORAGE_ROOT=/home/sam/files/1/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.2:12345 STORAGE_ROOT=/home/sam/files/2/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.3:12345 STORAGE_ROOT=/home/sam/files/3/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.4:12345 STORAGE_ROOT=/home/sam/files/4/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.5:12345 STORAGE_ROOT=/home/sam/files/5/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.6:12345 STORAGE_ROOT=/home/sam/files/6/objects/ go run dataServer/cmd/main.go &

# 启动接口服务节点
LISTEN_ADDRESS=172.16.18.1:12346 go run apiServer/cmd/main.go &
LISTEN_ADDRESS=172.16.18.2:12346 go run apiServer/cmd/main.go &

访问接口服务节点,PUT 一个名为 hello 的文件:

curl -v 172.16.18.1:12346/handleObjs/hello -XPUT -d"hello yushanma"

 用 locateObj 命令查看 hello 被保存到哪个数据服务节点上:

curl 172.16.18.1:12346/locateObj/hello

换另一个接口服务节点 GET 该对象:

curl 172.16.18.2:12346/handleObjs/hello

测试结果:

遇到的问题:

服务无法正常启动,是端口被占用的问题,换一个端口即可。

STORAGE_ROOT 指定存储根路径要加“/”,即 STORAGE_ROOT=/home/sam/files/1/objects/ ,否则存储的时候文件名会变成 objectshello ,而不是预期 objects 目录下的 hello 。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

余衫马

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

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

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

打赏作者

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

抵扣说明:

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

余额充值