Gin 框架学习笔记(02)— 参数自动绑定到结构体

参数绑定模型可以将请求体自动绑定到结构体中,目前支持绑定的请求类型有 JSONXMLYAML 和标准表单 form数据 foo=bar&boo=baz 等。换句话说,只要定义好结构体,就可以将请求中包含的数据自动接收过来,这是 Gin 框架非常神奇的功能。

在定义绑定对应的结构体时,需要给结构体字段设置绑定类型的标签,比如绑定 JSON 数据时,设置字段标签为 json:"fieldname" 。使用绑定可以更快捷地把数据传递给程序中的数据对象。

使用 Gin框架中系列绑定有关方法时,Gin 会根据请求头中 Content-Type 推断如何绑定,也就是自动绑定。但如果明确绑定的类型,开发人员也可以使用 MustBindWith() 方法或 BindJSON() 等方法而不用自动推断。可以指定结构体某字段是必需的,字段需要设置标签 binding:"required" ,但如果绑定时是空值,Gin 会报错。

Gin 框架的 binding 包中,定义了 Content-Type 请求头信息的多种 MIME 类型,以便在自动绑定时进行类型判别进而采用对应的处理方法:

const (
    MIMEJSON              = "application/json"
    MIMEHTML              = "text/html"
    MIMEXML               = "application/xml"
    MIMEXML2              = "text/xml"
    MIMEPlain             = "text/plain"
    MIMEPOSTForm          = "application/x-www-form-urlencoded"
    MIMEMultipartPOSTForm = "multipart/form-data"
    MIMEPROTOBUF          = "application/x-protobuf"
    MIMEMSGPACK           = "application/x-msgpack"
    MIMEMSGPACK2          = "application/msgpack"
    MIMEYAML              = "application/x-yaml"
)

在所有绑定的方法中,首先 c.Bind() 方法会根据 Content-Type 推断得到一个 bindding 实例对象。因为它会调用函数 func Default(method, contentType string) Binding ,这个函数根据 HTTP 请求的方法和 Content-Type 来实例化具体的 bindding 对象。一共可以实例化为下面几种类型:

var (
    JSON          = jsonBinding{}
    XML           = xmlBinding{}
    Form          = formBinding{}
    Query         = queryBinding{}
    FormPost      = formPostBinding{}
    FormMultipart = formMultipartBinding{}
    ProtoBuf      = protobufBinding{}
    MsgPack       = msgpackBinding{}
    YAML          = yamlBinding{}
    Uri           = uriBinding{}
    Header        = headerBinding{}
)

binding 包也就是 binding 目录中,可以看到每种实例结构都单独在一个文件定义了系列处理方法。 c.Bind() 方法得到 binding 实例对象后,会调用 c.MustBindWith(obj, b) 方法, b 为实例化的某类 binding 对象,而像 c.BindJSON() 方法由于知道实例化对象是 JSON ,所以也调用 c.MustBindWith(obj, b) ,这里的 bjsonBinding{} 对象。其他像 XML 等的处理过程类似。

c.MustBindWith() 方法会统一调用 c.ShouldBindWith() 方法,在 c.ShouldBindWith() 方法中会调用具体实例的处理方法: b.Bind(c.Request, obj) ,这个 b.Bind()方法很关键,每种 binding 实例对象都有实现这个方法,它实现了参数的绑定功能。

在参数绑定过程中,大致可以认为是这个过程:

Bind->MustBindWith->ShouldBindWith->b.Bind

在参数绑定中,无论是采用 c.Bind() 系列方法、或者是 c.ShouldBindWith() 系列方法,最终都是通过具体实例的 b.Bind() 方法来实现参数绑定到结构体指针。而这个实例可以在 binding 目录中找到其方法的实现文件。如: json.gouri.go 以及 form.go 等等文件,文件名都对应着不同的 Content-Type

Gin 框架中下列方法可以用处理绑定:

// Bind 检查 Content-Type 来自动选择绑定引擎
// 依靠 "Content-Type" 头来使用不同的绑定
//     "application/json" 绑定 JSON
//     "application/xml"  绑定 XML
// 否则返回错误信息
// 如果 Content-Type ==“application / json”,JSON 或 XML 作为 JSON 输入,
// Bind 会将请求的主体解析为 JSON。
// 它将 JSON 有效负载解码为指定为指针的结构。
// 如果输入无效,它会写入 400 错误并在响应中设置 Content-Type 标题 “text / plain” 。
func (c *Context) Bind(obj interface{}) error

// BindJSON 是 c.MustBindWith(obj, binding.JSON) 的简写
func (c *Context) BindJSON(obj interface{}) error

// BindXML 是 c.MustBindWith(obj, binding.BindXML) 的简写
func (c *Context) BindXML(obj interface{}) error

// BindQuery 是 c.MustBindWith(obj, binding.Query) 的简写
func (c *Context) BindQuery(obj interface{}) error

// BindYAML 是 c.MustBindWith(obj, binding.YAML) 的简写
func (c *Context) BindYAML(obj interface{}) error

// BindHeader 是 c.MustBindWith(obj, binding.Header) 的简写
func (c *Context) BindHeader(obj interface{}) error

// BindUri 使用 binding.Uri 绑定传递的结构体指针。
// 如果发生任何错误,它将使用 HTTP 400 中止请求。
func (c *Context) BindUri(obj interface{}) error

// MustBindWith 使用指定的绑定引擎绑定传递的 struct 指针。
// 如果发生任何错误,它将使用 HTTP 400 中止请求。
func (c *Context) MustBindWith(obj interface{}, b binding.Binding) error

// ShouldBind 检查 Content-Type 来自动选择绑定引擎
// 依靠 "Content-Type" 头来使用不同的绑定
//     "application/json" 绑定 JSON
//     "application/xml"  绑定 XML
// 否则返回错误信息
// 如果 Content-Type ==“application/json” ,JSON 或 XML 作为 JSON 输入,
// Bind 会将请求的主体解析为JSON。
// 它将 JSON 有效负载解码为指定为指针的结构。
// 类似 c.Bind() ,但这个方法在 JSON 无效时不支持写 400 到响应里。
func (c *Context) ShouldBind(obj interface{}) error

// ShouldBindJSON  是c.ShouldBindWith(obj, binding.JSON)的简写
func (c *Context) ShouldBindJSON(obj interface{}) error

// ShouldBindXML  是c.ShouldBindWith(obj, binding.XML)的简写
func (c *Context) ShouldBindXML(obj interface{}) error

// ShouldBindQuery  是c.ShouldBindWith(obj, binding.Query)的简写
func (c *Context) ShouldBindQuery(obj interface{}) error

// ShouldBindYAML  是c.ShouldBindWith(obj, binding.YAML)的简写
func (c *Context) ShouldBindYAML(obj interface{}) error

// ShouldBindHeader  是c.ShouldBindWith(obj, binding.Header)的简写
func (c *Context) ShouldBindHeader(obj interface{}) error

// ShouldBindUri使用指定的绑定引擎绑定传递的struct指针。
func (c *Context) ShouldBindUri(obj interface{}) error

// ShouldBindWith使用自定的绑定引擎绑定传递的struct指针。
func (c *Context) ShouldBindWith(obj interface{}, b binding.Binding) error

// ShouldBindBodyWith与ShouldBindWith类似,但它存储请求
// ShouldBindBodyWith可进入上下文,并在再次调用时重用。
//
// 注意:此方法在绑定之前读取正文。 所以推荐使用
// 如果只需要调用一次,那么ShouldBindWith可以获得更好的性能。
func (c *Context) ShouldBindBodyWith(obj interface{}, bb binding.BindingBody) (err error)

1. 绑定查询字符串或表单数据

表单和 URLQuery 方式传递参数,程序通过绑定的方式得到参数值,在参数的提取上更加自动。

package main

import (
    "log"

    "github.com/gin-gonic/gin"
)

type Person struct {
    Name     string    `form:"name"`
    Address  string    `form:"address"`
}

func main() {
    route := gin.Default()
    route.POST("/testing", startPage)
    route.Run(":8080")
}

func startPage(c *gin.Context) {
    var person Person
    // 如果是 `GET` 请求,只使用 `Form` 绑定引擎(`query`)。
    // 如果是 `POST` 请求,首先检查 `content-type` 是否为 `JSON` 或 `XML`,
    // 然后再使用 `Form`(`form-data`)。
    if c.ShouldBind(&person) == nil {
        log.Println(person.Name)
        log.Println(person.Address)
    }

    c.String(200, "Success")
}

程序运行在 Debug 模式时,在命令行运行下面三条命令:

curl -X POST  "http://localhost:8080/testing?name=appleboy&address=xyz"

curl -H "Content-Type:application/json"  -X POST -d '{"name":"appleeboy","address":"xyz"}' <http://localhost:8080/testing>

curl -H "Content-Type:application/x-www-form-urlencoded" -X POST -d "name=appleboy&address=xyz" "<http://localhost:8080/testing>"

输出结果:

[GIN-debug] Listening and serving HTTP on :8080
2019/07/13 12:54:34 appleboy
2019/07/13 12:54:34 xyz
[GIN] 2019/07/13 - 12:54:34 | 200 | 18.9504ms | 127.0.0.1 | POST /testing?name=appleboy&address=xyz
2019/07/13 12:54:38 appleeboy
2019/07/13 12:54:38 xyz
[GIN] 2019/07/13 - 12:54:38 | 200 | 0s | 127.0.0.1 | POST /testing
2019/07/13 12:54:46 appleboy
2019/07/13 12:54:46 xyz
[GIN] 2019/07/13 - 12:54:46 | 200 | 0s | 127.0.0.1 | POST /testing

通过 POST 方法,采用 Urlencoded 编码或 JSON 方式都能被绑定正常解析。但如果把程序接收方法改为 GET 方法:

route.GET("/testing", startPage)

则只能通过 URL Query 传递参数:

curl -X GET "http://localhost:8080/testing?name=appleboy&address=xyz"

这样通过 URL Query 传递参数也能被正常绑定。

2. Multipart/Urlencoded 绑定

通过表单传递参数,下面程序通过绑定的方式得到参数值。

type LoginForm struct {
    User     string `form:"user" binding:"required"`
    Password string `form:"password" binding:"required"`
}

func main() {
    router := gin.Default()
    router.POST("/login", func(c *gin.Context) {
        var form LoginForm
        // 可显式绑定表单
        // c.ShouldBindWith(&form, binding.Form)

        // 或者简单地使用 ShouldBind 方法自动绑定
        if c.ShouldBind(&form) == nil {
            if form.User == "user" && form.Password == "password" {
                c.JSON(200, gin.H{"status": "you are logged in"})
            } else {
                c.JSON(401, gin.H{"status": "unauthorized"})
            }
        }
    })
    router.Run(":8080")
}

上面程序中结构体的标签: form:"user" ,表示在 form 表单中的名为 user

User     string `form:"user" binding:"required"`
Password string `form:"password" binding:"required"`

程序运行在 Debug 模式时,在命令行运行下面两条命令:

curl -X POST  -d "user=user&password=password" <http://localhost:8080/login>

Curl -H "Content-Type:multipart/form-data" -X POST -d "user=user&password=password" http://localhost:8080/login

3. URI 参数绑定

Gin 框架支持在路由 URI 中存在参数,也支持通过绑定得到这些参数,需要在结构体中指定字段标签为 uri

package main

import "github.com/gin-gonic/gin"

type Person struct {
    ID string `uri:"id" binding:"required,uuid"`
    Name string `uri:"name" binding:"required"`
}

func main() {
    route := gin.Default()
    route.GET("/:name/:id", func(c *gin.Context) {
        var person Person
        if err := c.ShouldBindUri(&person); err != nil {
            c.JSON(400, gin.H{"msg": err})
            return
        }
        c.JSON(200, gin.H{"name": person.Name, "uuid": person.ID})
    })
    route.Run(":8088")
}

上面程序中结构体的标签: uri:"id" ,表示在 URI 中的参数名为 id

UserID  string `uri:"id" binding:"required"`
Name   string `uri:"name" binding:"required"`

程序运行在 Debug 模式时,在命令行运行下面命令:

curl  -X GET http://localhost:8080/Go/42

4. 绑定 HTML 复选框

Gin 框架很方便地通过绑定得到 HTML FORM 元素的值,需要在结构体中指定字段标签form:filedname

type CheckForm struct {
    Colors []string `form:"colors[]"`
}

func main() {
    router := gin.Default()

    router.Static("/", "./public")

    router.POST("/check", func(c *gin.Context) {
        var form CheckForm

        // 简单地使用 ShouldBind 方法自动绑定
        if c.ShouldBind(&form) == nil {
            c.JSON(200, gin.H{"color": form.Colors})
        }
    })
    router.Run(":8080")
}

index.html 文件放在程序目录下 public 目录中。

<form action="/check" method="POST">
    <p>Check some colors</p>
    <label for="red">Red</label>
    <input type="checkbox" name="colors[]" value="red" id="red">
    <label for="green">Green</label>
    <input type="checkbox" name="colors[]" value="green" id="green">
    <label for="blue">Blue</label>
    <input type="checkbox" name="colors[]" value="blue" id="blue">
    <input type="submit">
</form>

注意,上面程序中结构体标签: colors[] 与复选框的名字一致,这里表示数组所以可以得到多个已选项的值。
运行程序,通过浏览器访问 http://localhost:8080/ ,出现复选框表单,选择两个以上选项,这里选择红,绿两种颜色,然后提交表单(请求发送到 http://localhost:8080/check )。

页面显示,符合提交的选项:

{"color":["red","green"]}

5. 绑定表单数据至嵌入结构体

前面已经知道通过绑定可以自动取得数据到简单结构体对象,对有嵌入的结构体也可以通过绑定自动得到数据,不过嵌入的结构体后面不要指定标签。

type StructA struct {
    FieldA string `form:"field_a"`
}

type StructB struct {
    NestedStruct StructA // 不要指定标签
    FieldB string `form:"field_b"`
}

type StructC struct {
    NestedStructPointer *StructA
    FieldC string `form:"field_c"`
}

type StructD struct {
    NestedAnonyStruct struct {
        FieldX string `form:"field_x"`
    }
    FieldD string `form:"field_d"`
}

func GetDataB(c *gin.Context) {
    var b StructB
    c.Bind(&b)
    c.JSON(200, gin.H{
        "a": b.NestedStruct,
        "b": b.FieldB,
    })
}

func GetDataC(c *gin.Context) {
    var b StructC
    c.Bind(&b)
    c.JSON(200, gin.H{
        "a": b.NestedStructPointer,
        "c": b.FieldC,
    })
}

func GetDataD(c *gin.Context) {
    var b StructD
    c.Bind(&b)
    c.JSON(200, gin.H{
        "x": b.NestedAnonyStruct,
        "d": b.FieldD,
    })
}

func main() {
    router := gin.Default()
    router.GET("/getb", GetDataB)
    router.GET("/getc", GetDataC)
    router.GET("/getd", GetDataD)

    router.Run()
}

输入输出结果:

curl "http://localhost:8080/getb?field_a=hello&field_b=world"
Go{"a":{"FieldA":"hello"},"b":"world"}

curl "http://localhost:8080/getc?field_a=hello&field_c=world"
Go{"a":{"FieldA":"hello"},"c":"world"}

curl "http://localhost:8080/getd?field_x=hello&field_d=world"
Go{"d":"world","x":{"FieldX":"hello"}}

6. 将请求体绑定到不同的结构体中

一般通过调用 ShouldBind() 方法绑定数据,但注意某些情况不能多次调用这个方法。

type formA struct {
    Foo string `json:"foo" xml:"foo" binding:"required"`
}

type formB struct {
    Bar string `json:"bar" xml:"bar" binding:"required"`
}

func BindHandler(c *gin.Context) {
    objA := formA{}
    objB := formB{}
    // c.ShouldBind 使用了 c.Request.Body ,不可重用。
    if errA := c.ShouldBind(&objA); errA != nil {
        fmt.Println(errA)
        c.String(http.StatusOK, `the body should be formA`)
        // 因为现在 c.Request.Body 是 EOF,所以这里会报错。
    } else if errB := c.ShouldBind(&objB); errB != nil {
        fmt.Println(errB)
        c.String(http.StatusOK, `the body should be formB`)
    } else {
        c.String(http.StatusOK, `Success`)
    }
}

func main() {
    route := gin.Default()
    route.Any("/bind", BindHandler)
    route.Run(":8080")
}

运行程序,通过浏览器访问 http://localhost:8080/bind?foo=foo&bar=bar ,页面显示:
the body should be formA
程序运行在 Debug 模式时,在命令行运行下面命令:

curl -H "Content-Type:application/json" -v -X POST  -d '{"foo":"foo","bar":"bar"}'  http://localhost:8080/bind

命令返回:
the body should be formB
表明在第二次运行 ShouldBind() 方法时出错,要想多次绑定,可以使用 c.ShouldBindBodyWith() 方法。

func BindHandler(c *gin.Context) {
    objA := formA{}
    objB := formB{}
    // ShouldBindBodyWith() 读取 c.Request.Body 并将结果存入上下文。
    if errA := c.ShouldBindBodyWith(&objA, binding.JSON); errA != nil {

        fmt.Println(errA)
        c.String(http.StatusOK, `the body should be formA`)
        // 这时, 复用存储在上下文中的 body 。
    } else if errB := c.ShouldBindBodyWith(&objB, binding.JSON); errB != nil {
        fmt.Println(errB)
        c.String(http.StatusOK, `the body should be formB JSON`)
        // 可以接受其他格式
    } else {
        c.String(http.StatusOK, `Success`)
    }
}

c.ShouldBindBodyWith() 会在绑定之前将请求体存储到上下文中。 这会对性能造成轻微影响,如果调用一次就能完成绑定的话,那就不要用这个方法。
只有某些格式需要此功能,如 JSONXMLMsgPackProtoBuf 。对于其他格式,如 QueryFormFormPostFormMultipart 可以多次调用 c.ShouldBind() 而不会造成任任何性能损失,这也是前面结构体中的标签没有定义 form ,只有定义 json:"foo" xml:"foo" binding:"required" 的原因。

7. 只绑定 URL Query 参数

ShouldBind() 方法支持 URL Query 参数绑定,也支持 POST 参数绑定。而 ShouldBindQuery() 方法只绑定 URL Query 参数而忽略 POST 数据。

type Person struct {
    Name    string `form:"name"`
    Address string `form:"address"`
}

func startPage(c *gin.Context) {
    var person Person
    if c.ShouldBindQuery(&person) == nil {
        fmt.Println(person.Name)
        fmt.Println(person.Address)
        c.String(200, "Success")
    } else {
        c.String(400, "Error")
    }

}

func main() {
    route := gin.Default()
    route.Any("/bindquery", startPage)
    route.Run(":8080")
}

运行程序,通过浏览器访问 http://localhost:8080/ ,页面显示 “Sucess” 。输出结果:

[GIN-debug] GET /bindquery --> main.startPage (3 handlers)
[GIN-debug] POST /bindquery --> main.startPage (3 handlers)
[GIN-debug] PUT /bindquery --> main.startPage (3 handlers)
[GIN-debug] PATCH /bindquery --> main.startPage (3 handlers)
[GIN-debug] HEAD /bindquery --> main.startPage (3 handlers)
[GIN-debug] OPTIONS /bindquery --> main.startPage (3 handlers)
[GIN-debug] DELETE /bindquery --> main.startPage (3 handlers)
[GIN-debug] CONNECT /bindquery --> main.startPage (3 handlers)
[GIN-debug] TRACE /bindquery --> main.startPage (3 handlers)
[GIN-debug] Listening and serving HTTP on :8080
titan
cs
[GIN] 2019/07/13 - 17:06:23 | 200 | 0s | ::1 | GET /bindquery?name=titan&address=cs

输出表明 URL Query 参数通过 GET 方法能被程序正常绑定,注意上面程序中使用了 Any() 方法,它能匹配众多的 HTTP 方法。
如果程序继续运行在 Debug 模式时,在命令行运行下面命令:

curl -v -X POST  -d "name=titan&address=cs"  http://localhost:8080/bindquery


* Connected to localhost (::1) port 8080 (#0)
> POST /bindquery HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Length: 21
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 21 out of 21 bytes
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Date: Sat, 13 Jul 2019 17:12:37
< Content-Length: 7
<
Success

命令行的返回表明通过 POST 方法已经成功提交请求,服务端成功返回,状态代码: 200 ,返回内容: Success 。
控制台输出结果:

[GIN] 2019/07/13 - 17:12:37 | 200 | 0s | ::1 | POST /bindquery

从控制台输出可以看到,通过 POST 提交的数据没有正常绑定。但是前面通过 ShouldBind() 方法可以正常绑定。这表明 ShouldBindQuery() 只绑定 URL Query 参数而忽略 POST 数据。

8. JSON 模型绑定

通过 POST 方法提交 JSON 格式数据,程序通过绑定的方式得到 JSON 数据,并传递给结构体,但需要指定字段标签为 json

// 绑定 JSON
type Login struct {
    User     string `form:"user" json:"user" xml:"user"  binding:"required"`
    Password string `form:"password" json:"password" xml:"password" binding:"required"`
}

func main() {
    router := gin.Default()

    // 绑定 JSON ({"user": "manu", "password": "123"})
    router.POST("/loginJSON", func(c *gin.Context) {
        var json Login
        if err := c.ShouldBindJSON(&json); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }

        if json.User != "manu" || json.Password != "123" {
            c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
            return
        }

        c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
    })

    // 绑定 HTML 表单 (user=manu&password=123)
    router.POST("/loginForm", func(c *gin.Context) {
        var form Login
        // 根据 Content-Type Header 推断使用哪个绑定器。
        if err := c.ShouldBind(&form); err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
            return
        }

        if form.User != "manu" || form.Password != "123" {
            c.JSON(http.StatusUnauthorized, gin.H{"status": "unauthorized"})
            return
        }

        c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
    })

    // 监听并启动服务
    router.Run(":8080")
}

输出结果:

curl -v  -H 'content-type: application/json' -X POST  http://localhost:8080/loginJSON   -d '{ "user": "manu" , "password" :"123" }'

> POST /loginJSON HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> content-type: application/json
> Content-Length: 38
>
* upload completely sent off: 38 out of 38 bytes
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Sat, 13 Jul 2019 15:08:29 GMT
< Content-Length: 31
<
{"status":"you are logged in"}

9. Header 头信息绑定

Header 也可以传递参数,程序通过绑定的方式得到参数值,在结构体的字段标签上需要指定为 header

type testHeader struct {
    Rate   int    `header:"Rate"`
    Domain string `header:"Domain"`
}

func main() {
    router := gin.Default()
    router.GET("/", func(c *gin.Context) {
        h := testHeader{}

        if err := c.ShouldBindHeader(&h); err != nil {
            c.JSON(200, err)
        }

        fmt.Printf("%#v\\n", h)
        c.JSON(200, gin.H{"Rate": h.Rate, "Domain": h.Domain})
    })

    router.Run(":8080")
}

运行命令

curl -H "rate:300" -H "domain:music" http://localhost:8080/

{"Domain":"music","Rate":300}

通过 curl 命令带上自定义的头部信息给 Handler 处理程序, ShouldBindHeader() 方法自动绑定头部变量到结构体。

参考:https://gitbook.cn/gitchat/column/5dab061e7d66831b22aa0b44/topic/5dab09f37d66831b22aa0b5d

  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值