golang使用技巧与易错点总结

指针

指针常见的异常有:指针变量未初始化而指针引用里面的属性、空指针引用、空指针判空或长度。

*&

*可以表示指针变量,使用时,作用于指针变量前表示取值。
&取地址符,引用变量,表示取对应变量的地址,不可作用于函数。

package main

import "fmt"
import "strings"

func main() {
   var p1, p2 *string
	p3:= "123-678"
	p1 = &p3
	// p1指针变量,p3值变量
	fmt.Printf("origin p3:%+v, convert after p1:%+v\n", p3, p1)
	// `&`无论作用于指针变量,还是值变量,得到的都是其对应的地址
	fmt.Printf("origin p3:%+v, convert after p1:%+v\n", &p3, &p1)

	p1Copy :=getString(p1)
	p2Copy := getString(p2)
	fmt.Printf("order 1, origin p1:%+v, convert after:%+v\n", *p1, *getString(p1))
	// p1Copy是非nil的指针变量,其前可以加`&`取地址符
	fmt.Printf("order 2, origin p1:%+v, convert after:%+v\n", *p1, &p1Copy)
	// 注意getString为函数,虽然也是指针类型,但是其前面不能加`&`取地址符,否则编译异常`./main.go:xx: cannot take the address of getString(p1)`
	//fmt.Printf("order 3, origin p1:%+v, convert after:%+v\n", *p1, &getString(p1))
	// 注意,由于p2指针未初始化,这里p2和p2Copy都为nil
	fmt.Printf("order 3, origin p2:%+v, convert after:%+v\n", p2, p2Copy)
	// 注意,由于p2指针未初始化,这里p2和p2Copy都为nil,而nil类型的指针变量通过`&`取地址符是可以取到为nil的值的
	fmt.Printf("order 4, origin p2:%+v, convert after:%+v\n", p2, &p2Copy)
	// 注意这里的p2Copy为nil空指针,无地址,故*p2Copy是无法根据地址取到对应地址里面的值的
	fmt.Printf("order 5, origin p2:%+v, convert after:%+v\n", p2, *p2Copy)
	// 注意这里的p2为nil空指针,无地址,故*p2是无法根据地址取到对应地址里面的值的
	fmt.Printf("order 6, origin p2:%+v, convert after:%+v\n", *p2, p2Copy)
}

func getString(p *string) *string {
	var res string
	if p == nil {
		fmt.Printf("pointer:%p, value:%v\n", p, p)
		return nil
	}
	
	if strings.Contains(*p, "-") {
		res = strings.Replace(*p, "-", ";", -1)
		return &res
	}
	
	return p
}

对应结果:

origin p3:123-678, convert after p1:0xc42000e220
origin p3:0xc42000e220, convert after p1:0xc42000c028
pointer:0x0, value:<nil>
order 1, origin p1:123-678, convert after:123;678
order 2, origin p1:123-678, convert after:0xc42000c028
order 3, origin p2:<nil>, convert after:<nil>
order 4, origin p2:<nil>, convert after:0xc42000c030
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x485257]

goroutine 1 [running]:
main.main()
	/home/cg/root/28475472/main.go:17 +0x407
exit status 2

指针使用注意事项

注意事项:

  • &是取地址符,顾名思义,&无论作用于指针变量,还是值变量,得到的都是其对应的地址。
  • &是取地址符,但无法作用于函数,因为函数是动态类型,在运行时才能确定其对应的地址,在其前加上&会在编译时就给出异常。
  • *在变量声明时表明该变量是指针变量,使用时表示取对应指针变量的值,即通过指针变量的地址找到该地址里面存放的值。
  • nil类型的指针变量,通过&取地址符是可以取到对应地址为nil的值。
  • nil类型指针的变量,地址为空也即无地址,无法根据地址通过加*的方式取到对应地址里面的值,故也无法对nil类型指针变量的长度加以判断,否则会引发panic: runtime error: invalid memory address or nil pointer dereference
  • 判断指针类型字符串是否为空,不能仅仅判断长度,需要先判断非nil后,才能进一步通过地址找到里面对应的值,进而判断其值是否为空。

指针字符串判空

可以通过如下方式加以判断:

var p *string
// p != nil才会进行逻辑与后面的长度判断,如果未进行p == nil而直接len(*p)取长度,会因为无地址而找不到对应的值,进而引发`panic: runtime error: invalid memory address or nil pointer dereference`空指针引用异常。
if p != nil && len(*p) != 0 {
	fmt.Println("p not empty")
}

数组与切片

数组

package main

import "fmt"

func main() {
    var a = [5]int{101, 102, 103, 104, 105}
    var r [5]int

    fmt.Println("original a =", a)
	// aa := a[:]
	// aCopy := make([]int, len(aa))
	// copy(aCopy, aa)
    for i, v := range a {
        if i == 0 {
            a[1] = 1200
            a[2] = 1300
        }
        r[i] = v
    }

    fmt.Println("after for range loop, r =", r)
    fmt.Println("after for range loop, a =", a)
}

结果如下:

original a = [101 102 103 104 105]
after for range loop, r = [101 102 103 104 105]
after for range loop, a = [101 1200 1300 104 105]

数组转切片

将数组转为slice切片后,使用copy进行值拷贝,再进行结果对比。

package main

import "fmt"

func main() {
    var a = [5]int{101, 102, 103, 104, 105}
    var r [5]int

    fmt.Println("original a =", a)
	aa := a[:]
	aCopy := make([]int, len(aa))
	copy(aCopy, aa)
    for i, v := range aCopy {
        if i == 0 {
            a[1] = 1200
            a[2] = 1300
        }
        r[i] = v
    }

    fmt.Println("after for range loop, r =", r)
    fmt.Println("after for range loop, a =", a)
}

结果:

original a = [101 102 103 104 105]
after for range loop, r = [101 102 103 104 105]
after for range loop, a = [101 1200 1300 104 105]

切片拷贝

将原数组换成切片,再看效果

package main

import "fmt"

func main() {
    var a = []int{101, 102, 103, 104, 105}
    var r []int
	r = make([]int, len(a))
	aa := a[:]
	aCopy := make([]int, len(aa))
	copy(aCopy, aa)
	
	fmt.Printf("original r =%v, addr = %p \n", r, r)
    fmt.Printf("original a =%v, addr = %p \n", a, a)
	fmt.Printf("original aa =%v, addr = %p \n", aa, aa)
    fmt.Printf("original aCopy =%v, addr = %p \n", aCopy, aCopy)
	
    for i, v := range a {
        if i == 0 {
            a[1] = 1200
            a[2] = 1300
        }
        r[i] = v
		//a = append(a, i)
		//r = append(r, i)
    }

    fmt.Printf("after for range loop, r =%v, addr = %p \n", r, r)
    fmt.Printf("after for range loop, a =%v, addr = %p \n", a, a)
	fmt.Printf("after for range loop, aa =%v, addr = %p \n", aa, aa)
    fmt.Printf("after for range loop, aCopy =%v, addr = %p \n", aCopy, aCopy)
}

结果如下:

original r =[0 0 0 0 0], addr = 0xc4200c4030 
original a =[101 102 103 104 105], addr = 0xc4200c4000 
original aa =[101 102 103 104 105], addr = 0xc4200c4000 
original aCopy =[101 102 103 104 105], addr = 0xc4200c4060 
after for range loop, r =[101 1200 1300 104 105], addr = 0xc4200c4030 
after for range loop, a =[101 1200 1300 104 105], addr = 0xc4200c4000 
after for range loop, aa =[101 1200 1300 104 105], addr = 0xc4200c4000 
after for range loop, aCopy =[101 102 103 104 105], addr = 0xc4200c4060 

切片append

使用append,追加元素后

package main

import "fmt"

func main() {
    var a = []int{101, 102, 103, 104, 105}
    var r []int
	r = make([]int, len(a))
	aa := a[:]
	aCopy := make([]int, len(aa))
	copy(aCopy, aa)
	
	fmt.Printf("original r =%v, addr = %p \n", r, r)
    fmt.Printf("original a =%v, addr = %p \n", a, a)
	fmt.Printf("original aa =%v, addr = %p \n", aa, aa)
    fmt.Printf("original aCopy =%v, addr = %p \n", aCopy, aCopy)
	
    for i, v := range a {
        if i == 0 {
            a[1] = 1200
            a[2] = 1300
        }
        r[i] = v
		a = append(a, i)
		r = append(r, i)
    }

    fmt.Printf("after for range loop, r =%v, addr = %p \n", r, r)
    fmt.Printf("after for range loop, a =%v, addr = %p \n", a, a)
	fmt.Printf("after for range loop, aa =%v, addr = %p \n", aa, aa)
    fmt.Printf("after for range loop, aCopy =%v, addr = %p \n", aCopy, aCopy)
}

结果:

original r =[0 0 0 0 0], addr = 0xc4200141b0 
original a =[101 102 103 104 105], addr = 0xc420014180 
original aa =[101 102 103 104 105], addr = 0xc420014180 
original aCopy =[101 102 103 104 105], addr = 0xc4200141e0 
after for range loop, r =[101 1200 1300 104 105 0 1 2 3 4], addr = 0xc4200120f0 
after for range loop, a =[101 1200 1300 104 105 0 1 2 3 4], addr = 0xc4200120a0 
after for range loop, aa =[101 1200 1300 104 105], addr = 0xc420014180 
after for range loop, aCopy =[101 102 103 104 105], addr = 0xc4200141e0 

map

json反序列化未使用指针类型

将序列化后的字符串,通过json.Unmarshal反序列化为map时,需要使用指针类型的map形式,否则报错json unmarshal failed json: Unmarshal(non-pointer map[string]string)

package main

import "fmt"
import "encoding/json"

func main() {
	config := "{\"properties\":\"{\\\"property_211\\\":\\\"211\\\"}\"}"
	m := make(map[string]string, 0)
	if err := json.Unmarshal([]byte(config), &m); err != nil {
		fmt.Println("json unmarshal failed", err)	
		return 
	}
	fmt.Println("config 1", m)
	
	mm := make(map[string]string, 0)
	if err := json.Unmarshal([]byte(config), mm); err != nil {
		fmt.Println("json unmarshal failed", err)	
		return 
	}
	fmt.Println("config 2", mm)
}

结果:

config 1 map[properties:{"property_211":"211"}]
json unmarshal failed json: Unmarshal(non-pointer map[string]string)

空字符串反序列化错误

注意:空字符串,反序列化为map会报错 unmarshal failed, unexpected end of JSON input,可以对空字符串重置为{},然后再反序列化为map[string]interface{}类型。如果确定,要反序列化的字符串的key和value都是字符串类型,而没有像数组、对象等类型,可以将其反序列化为map[string]string类型。

package main

import "fmt"
import "encoding/json"

func main() {
   fmt.Println("Hello, World!")
	tag := `{"tags":"aaa,231111"}`
	tag2 := `{"tags":"6_16"}`
	tagMap := make(map[string]string)
	if err := json.Unmarshal([]byte(tag), &tagMap); err != nil {
		fmt.Printf("unmarshal failed, %s\n", err.Error())	
		return 
	}
	fmt.Printf("unmarshal success, %+v\n", tagMap)	
	if val, ok := tagMap["tags"]; ok {
		fmt.Println("hit, val:", val)	
	}
	tagMap = make(map[string]string)
	if err := json.Unmarshal([]byte(tag2), &tagMap); err != nil {
		fmt.Printf("unmarshal failed, %s\n", err.Error())	
		return 
	}
	fmt.Printf("unmarshal success, %+v\n", tagMap)	
	if val, ok := tagMap["tags"]; ok {
		fmt.Println("2: hit tags, val:", val)	
	}
	
	// 如果tag3为空字符串``,反序列化报错`unmarshal failed, unexpected end of JSON input`
	tag3 := `{}`
	tagMap = make(map[string]string)
	if err := json.Unmarshal([]byte(tag3), &tagMap); err != nil {
		fmt.Printf("unmarshal failed, %s\n", err.Error())	
		return 
	}
	fmt.Printf("unmarshal success, %+v\n", tagMap)	
	if val, ok := tagMap["tags"]; ok {
		fmt.Println("hit, val:", val)	
	} else {
		fmt.Println("not hit")	
	}
}

结果为:

Hello, World!
unmarshal success, map[tags:aaa,231111]
hit, val: aaa,231111
unmarshal success, map[tags:6_16]
2: hit tags, val: 6_16
unmarshal success, map[]
not hit

channel

channel未初始化

channel阻塞

http

golang中跟网络相关的问题,比如没有设置超时时间,导致接口调用超时或者不通;请求完接口后,忘记关闭body体,导致连接耗尽,服务请求403等问题。

body忘关闭导致TCP连接耗尽

这个 TCP 问题你得懂:Cannot assign requested address
can’t assign requested address 错误解决
关于 Golang 中 http.Response.Body 未读取导致连接复用问题的一点研究
记得正确处理 http.Response.Body
Cannot assign requested address出现的原因及解决方案(转)

golang http.Response.Body的正确处理方式

出现的问题

查看sch项目detail日志

$ zgrep 68c7dd42-70cc-4723-af09-bf8f8defbe07 detail.log.2022-06-06.0.gz 

2022-06-06 18:37:18.101 ERROR [sch/manager.(*Manager).dispatch:799] - () execute http request for url http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock failed with resp: &{Status:403 Forbidden StatusCode:403 Proto:HTTP/1.1 ProtoMajor:1 ProtoMinor:1 Header:map[Cache-Control:[no-cache, no-store] Content-Length:[167] Content-Type:[application/json; charset=utf-8] Date:[Mon, 06 Jun 2022 10:37:18 GMT]] Body:0xc0051b7540 ContentLength:167 TransferEncoding:[] Close:false Uncompressed:false Trailer:map[] Request:0xc014d1ea00 TLS:<nil>}
2022-06-06 18:37:20.130 ERROR [sch/manager.(*Manager).dispatch:799] - () execute http request for url http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock failed with resp: &{Status:403 Forbidden StatusCode:403 Proto:HTTP/1.1 ProtoMajor:1 ProtoMinor:1 Header:map[Cache-Control:[no-cache, no-store] Content-Length:[167] Content-Type:[application/json; charset=utf-8] Date:[Mon, 06 Jun 2022 10:37:20 GMT]] Body:0xc000d7df00 ContentLength:167 TransferEncoding:[] Close:false Uncompressed:false Trailer:map[] Request:0xc014a47400 TLS:<nil>}
2022-06-06 18:37:22.123 ERROR [sch/manager.(*Manager).dispatch:799] - () execute http request for url http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock failed with resp: &{Status:403 Forbidden StatusCode:403 Proto:HTTP/1.1 ProtoMajor:1 ProtoMinor:1 Header:map[Cache-Control:[no-cache, no-store] Co^C
$ cat detail.log | grep 68c7dd42-70cc-4723-af09-bf8f8defbe07
2022-06-07 01:22:09.873 ERROR [xxx/manager.(*Manager).dispatch:797] - () Get "http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock": dial tcp xx:xxxx: connect: cannot assign requested address
2022-06-07 01:22:10.298 ERROR [xxx/manager.(*Manager).dispatch:797] - () Get "http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock": dial tcp xx:xxxx: connect: cannot assign requested address
2022-06-07 01:22:10.526 ERROR [xxx/manager.(*Manager).dispatch:797] - () Get "http://xx:xxxx/xxx/68c7dd42-70cc-4723-af09-bf8f8defbe07/onlock": dial tcp xx:xxxx: connect: cannot assign requested address
2022-06-07 01:22:11.904 ERROR [git.forwardx.ai/a4stack/erms/schedule-manager/manager.(*Manager).dispatch:797] - () Ge^C

可以看到,由于dial tcp xx:xxxx: connect: cannot assign requested address,而造成请求接口403禁止访问。查阅该问题,是由于socket连接被占用完导致。

查看mysql数据:

$mysql -h127.0.0.1 -uroot

出现cannot assign requested address的原因

产生这个错误的原因是由于 Linux 分配的客户端连接端口用尽,无法建立 socket 连接导致的。

我们都知道,建立一个连接需要四个部分:目标 IP,目标端口,客户端 IP 和客户端端口。其中前三项是不变的,只有客户端端口不断变化。

那么在大量频繁建立连接时,而端口又不是立即释放,默认是 60s,就会出现客户端端口不够用的情况。

这就是这个问题的本质。

接下来使用两个命令来验证一下:

查看连接数:

# netstat -ae | wc -l
# netstat -ae | grep TIME_WAIT | wc -l

查看可用端口范围:

# sysctl -a | grep port_range
net.ipv4.ip_local_port_range = 50000    65000

结果就是连接数是远大于可用端口数的。

解决方式

怎么解决呢?有两个方案:

  • 调低 TIME_WAIT 时间
  • 调高可用端口范围
调低 TIME_WAIT 时间

编辑内核文件 /etc/sysctl.conf,增加以下内容:

// 表示开启 SYN Cookies。当出现 SYN 等待队列溢出时,启用 cookies 来处理,
// 可防范少量 SYN 攻击,默认为 0,表示关闭;
net.ipv4.tcp_syncookies = 1 
// 表示开启重用。允许将 TIME-WAIT sockets 重新用于新的 TCP 连接,默认为 0,表示关闭;
net.ipv4.tcp_tw_reuse = 1 
// 表示开启 TCP 连接中 TIME-WAIT sockets 的快速回收,默认为 0,表示关闭。
net.ipv4.tcp_tw_recycle = 1 
// 修改系默认的 TIMEOUT 时间,默认为 60s 
net.ipv4.tcp_fin_timeout = 30
调高可用端口范围

编辑内核文件 /etc/sysctl.conf,增加以下内容:

// 表示用于向外连接的端口范围。设置为 1024 到 65535。
net.ipv4.ip_local_port_range = 1024 65535 
最后,执行 sysctl -p 使参数生效。

问题复盘

代码,处理回调时,忘记defer resp.Body.Close()以关闭连接body。

req, err = http.NewRequest("GET", request.RequestURL, bytes.NewBuffer([]byte("")))
if err != nil {
	fmt.Println(err.Error())
	result = "failed"
} else {
	resp, err := client.Do(req)
	if err != nil || resp.StatusCode != 200 {
		if err != nil {
			fmt.Println(err.Error())
		} else {
			fmt.Errorf("request url %s failed with resp: %+v", request.RequestURL, resp)
		}
		result = "failed"
	}
	// 忘记关闭body
	// resp.Body.Close()
}

在 Golang 的 HTTP 库的源代码中,关于 http.Response.Body 的说明如下:

// Body represents the response body.
//
// The response body is streamed on demand as the Body field
// is read. If the network connection fails or the server
// terminates the response, Body.Read calls return an error.
//
// The http Client and Transport guarantee that Body is always
// non-nil, even on responses without a body or responses with
// a zero-length body. It is the caller's responsibility to
// close Body. The default HTTP client's Transport may not
// reuse HTTP/1.x "keep-alive" TCP connections if the Body is
// not read to completion and closed.
//
// The Body is automatically dechunked if the server replied
// with a "chunked" Transfer-Encoding.
//
// As of Go 1.12, the Body will also implement io.Writer
// on a successful "101 Switching Protocols" response,
// as used by WebSockets and HTTP/2's "h2c" mode.
Body io.ReadCloser

注意到其中的一句话:The default HTTP client’s Transport may not reuse HTTP/1.x “keep-alive” TCP connections if the Body is not read to completion and closed., 它说,如果 Body 未被读完且关闭的话,默认的 HTTP 客户端的传输层是不会复用 HTTP/1.x 的 “Keep-Alive” 连接的。 (在 HTTP/1.0 的时代,“Keep-Alive” 还不是默认行为,如果浏览器和服务器支持,可以在请求头和响应头加上“Connection: Keep-Alive”。 然而在 HTTP/1.1 中,“Keep-Alive” 是默认行为,除非特别声明了不允许(比如 Connection: close)^。)

为什么要连接复用?当然是基于性能的考虑啦。多路复用能不香吗?

日常写代码的方式

而再看我们平常写的代码,似乎很少有人注意到这点(只是发送 HTTP 请求,不需要数据的情况):

// 注意是 HTTP(80) 端口的连接,大量用于内网
resp, err := http.Get(`http://www.example.com`)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

如果只是忘记 Body.Close(),大概率 linter 是过不了的。但是却没有人提醒你,Body 该不该被读走。 按照文档所述,如果 Body 未被读完的话,连接是不会被复用的。为什么?我认为非常简单:因为 HTTP 客户端无法知道你是否还会用到 Body。 它可不敢帮你读走(以完成一次 Request/Response)以主动复用连接,毕竟万一网络突然卡了,读取 Body 占用大量时间。 本来 Close 一下是很快的,结果因为想要复用却导致一大堆不能用且没关闭的连接。这在标准库的设计上来说是不可取的。

按照 Go 语言中 http.Request/Response 的实现原理:服务端在读完请求(Request)的头部(Headers),或客户端在读完响应(Response)的头部(Headers),以后就算完成了请求或响应的读取,Body 被包装成 ReadCloser 接口作为流交给程序后面自己去读取(并关闭)。这一点非常好,不会因为请求或响应包含一个非常大的 Body 而占用大量内存。

应该考虑的写法

综上所述,文档中关于连接复用的现象的说法就是必然存在的了。 那么我们是不是总是应该在不需要 Body 的地方执行一句

io.Copy(ioutil.Discard, resp.Body)

以读完 Body 呢?就像 defer resp.Body.Close() 需要总是记得关闭那样。

答案我不确定:如果能确定接口非常快并且响应数据少或没有数据,那么执行一下可以想像并无性能损失。但是万一网络卡住,那么,读取 Body 的时候就会耗时很久了。 所以,按需吧。如果你在编写代码时能考虑到这一点(或者加个注释也行),那也是更严谨的体现。

一个用于验证连接复用的例子

下面是一段测试代码,用于验证读取并关闭resp.Body后连接是否真正复用:

package main

import (
	"flag"
	"io"
	"io/ioutil"
	"net/http"
)

func issue(discard bool) {
	resp, err := http.Get(`http://www.example.com`)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	if discard {
		// 读取并关闭resp.Body,tcp连接才真正会被复用,可以并发调用加以验证
		io.Copy(ioutil.Discard, resp.Body)
	} else {
		// 不读取并关闭resp.Body
		fmt.Println("not read resp.Body")
	}
}

func main() {
	var discard bool
	flag.BoolVar(&discard, `d`, false, `discard body`)
	flag.Parse()

	issue(discard)
	issue(true) // whatever
}

像下面这样,不读走 Body 调用时:

$ sudo strace -qqfe connect ./discard -d=false 2>&1 | grep ‘(80)’
[pid 23647] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr(“93.184.216.34”)}, 16) = -1 EINPROGRESS (Operation now in progress)
[pid 23649] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr(“93.184.216.34”)}, 16) = -1 EINPROGRESS (Operation now in progress)
多次执行,均能看到同样的结果:发起了 2 次 connect 建立连接。是不是说明连接没有复用?我想是的。 虽然两次的文件描述符都是 6,那是因为每次都关闭了连接,并且按照 Unix 的特点:文件描述符总是从最小未被使用的开始。

而当读走 Body 调用时:

$ sudo strace -qqfe connect ./discard -d=true 2>&1 | grep ‘(80)’
[pid 23743] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr(“93.184.216.34”)}, 16) = -1 EINPROGRESS (Operation now in progress)
均只能看到只有 1 次 connect 的调用。 是不是已经能说明连接确实被复用了?我想是的。

参考

Go 遍历超大列表用 range 和 for 哪个效率高?
Golang channel 三大坑,你踩过了嘛?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

love666666shen

谢谢您的鼓励!

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

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

打赏作者

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

抵扣说明:

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

余额充值