Golang基础笔记

前言

Go简介

  • Google开源
  • 编译型语言
  • 21世界的C语言

2005年出现多核处理器,其他语言都是单核时代诞生的。Go天生考虑了多核并发。

特点:

  • 语法简洁(只有25个关键字,比Python更简洁,自带格式化,互相阅读容易)
  • 开发效率高
  • 执行性能好(接近java)

发展:

百度自动驾驶,小程序

腾讯蓝鲸,微服务框架

知乎最早用python写,后期承受不了负载,用go重构节约了80%资源。

课程简介

李文周博客

仓库

8周基础

3个实战项目

Go项目结构

个人开发者

image-20220113164729380

流行方式

image-20220113165000771

Helloworld

go build

win编译得exe,macos得可执行文件

go install

install相当于build后再移到bin

go run

当脚本运行

支持跨平台交叉编译

// wincmd SET, macos export
export CGO_ENABLED=0 //禁用CGO
export GOOS=linux //设置目标平台linux,windows,darwin
export GOARCH=amd64//目标处理器架构是amd64
go build
export CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build

变量与常量

函数外不能写语句

标识符:字母数字下划线,不以数字开头

关键字与保留字不建议用于变量名

变量

初始化

数字默认0,字符串默认空,布尔默认false,切片、函数、指针默认nil

var 变量名 类型 = 表达式
var name string = "Q1mi"
var age int = 18
var name, age = "Q1mi", 20 //会根据值推导类型
var (
a string
b int
c bool
d float32
)

写在函数外为全局变量

函数内声明局部变量简写为

n := 10
m := 200
fmt.Println(m, n)

注意:在Golang里非全局变量声明必须使用,不然编译不通过!

fmt.Print()
fmt.Printf()
fmt.Println() //换行

保存时会自动格式化

命名规则

var studentName string

Golang使用小驼峰命名

匿名变量

用短下划线接收,不占命名空间,不分配内存

x, _ = foo()
_, y = foo()

常量

const pi = 3.14

iota

常量计数器,每新增一行常量声明则计数,注意是一行

const (
n1 = iota //0
n2 //1
n3 //2
n4 //3
)
const (
n1 = iota //0
n2 //1
_ //2 但是被丢弃
n3 //3
)

定义数量级

const (
_ = iota
KB = 1 << (10 * iota)
MB = 1 << (10 * iota)
GB = 1 << (10 * iota)
TB = 1 << (10 * iota)
PB = 1 << (10 * iota)
)

<< 左移符号,二进制1左移10位是1024

基本数据类型

整型分为以下两个大类:按长度分为:int8、int16、int32、int64

对应的无符号整型:uint8、uint16、uint32、uint64

uint8就是byte,int16就是short,int64是long

特殊整型

uint int 会根据系统判别是32还是64

uintptr 指针,存放内存地址

进制

Golang无法直接定义二进制数,八、十六均可

// 十进制
var a int = 10
fmt.Printf("%d \n", a) // 10
fmt.Printf("%b \n", a) // 1010 占位符%b表示二进制

// 八进制 以0开头
var b int = 077
fmt.Printf("%o \n", b) // 77

// 十六进制 以0x开头
var c int = 0xff
fmt.Printf("%x \n", c) // ff
fmt.Printf("%X \n", c) // FF
fmt.Printf("%T \n", c) // 输出类型
fmt.Printf("%v \n", c) // 输出变量值,任意类型

浮点数

golang中小数默认float64

math.MaxFloat64 // float64最大值

布尔

默认false,不允许转换

字符串

只能双引号,单引号为字符

转义 含义
\r 返回行首
\n 换行(下行同列)
\t 制表
// 在win中路径转义
s := "D:\\Documents\\A"

// 反引号原样输出, 多行字符串
s := `
asda
asd
`
s := "D:\Documents\A"
len(str)
ss := s1 + s2
ret := strings.Split(s3, "\\")
ret = strings.Contains(s3, "abcd")
ret = strings.HasPrefix(s3, "abcd")
ret = strings.HasSufix(s3, "abcd")
ret = strings.Index(s3, "c")
ret = strings.LastIndex(s3, "c")
ret = strings.Join(a, b)

英文字符为byte,其他语系如中文字符为rune,实际为int32,占3位

字符串遍历

for _, char := range str {
fmt.Printf("%c", char)
}

字符串没法直接修改,只能转换为其他类型处理

s3 := []rune(s2)	//切片
s3[0] = 'e' //修改
s4 := string(s3)

流程控制

if

if 表达式1 {
分支1
} else if 表达式2 {
分支2
} else{
分支3
}
// 局部变量score只在if中生效,减少内存占用
if score := 65; score >= 90 {
fmt.Println("A")
} else if score > 75 {
fmt.Println("B")
} else {
fmt.Println("C")
}

for

golang只有for

for 初始语句;条件表达式;结束语句{
循环体语句
}
for i := 0; i < 10; i++ {
fmt.Println(i)
}

初始语句和结束语句可省略,相当于while

i := 0
for i < 10 {
fmt.Println(i)
i++
}

无限循环

for {
循环体语句
}

通过breakgotoreturnpanic语句强制退出循环

遍历

for range遍历数组、切片、字符串、map 及通道(channel)

for i,v := range s{
fmt.Println(i, v)
}
  1. 数组、切片、字符串返回索引和值。
  2. map返回键和值。
  3. 通道(channel)只返回通道内的值。

switch

finger := 3
switch finger {
case 1:
fmt.Println("大拇指")
fallthrough
case 2:
fmt.Println("食指")
case 3:
fmt.Println("中指")
case 4:
fmt.Println("无名指")
case 5:
fmt.Println("小拇指")
default:
fmt.Println("无效的输入!")
}

fallthrough语法可以执行满足条件的case的下一个case,是为了兼容C语言中的case设计的

switch n := 7; n {
case 1, 3, 5, 7, 9:
fmt.Println("奇数")
case 2, 4, 6, 8:
fmt.Println("偶数")
default:
fmt.Println(n)
}

goto

goto语句通过标签进行代码间的无条件跳转。goto语句可以在快速跳出循环、避免重复退出上有一定的帮助。Go语言中使用goto语句能简化一些代码的实现过程。 例如双层嵌套的for循环要退出时

var breakFlag bool
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if j == 2 {
// 设置退出标签
breakFlag = true
break
}
fmt.Printf("%v-%v\n", i, j)
}
// 外层for循环判断
if breakFlag {
break
}
}

简化为

for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if j == 2 {
// 设置退出标签
goto breakTag
}
fmt.Printf("%v-%v\n", i, j)
}
}
return
// 标签
breakTag:
fmt.Println("结束for循环")

运算符

++(自增)和--(自减)在Go语言中是单独的语句,并不是运算符。

// 逻辑运算
&&
||
!

// 位运算
&
|
^
<<
>>

// 赋值
+=
-=
<<=

数组

初始化

数组从声明时就确定,使用时可以修改数组成员,但是数组大小不可变化

var a [3]int

var a [3]int
var b [4]int
a = b //不可以这样做,因为此时a和b是不同的类型

数组可以通过下标进行访问,下标是从0开始,最后一个元素下标是:len-1,访问越界(下标在合法范围之外),则触发访问越界,panic

var testArray [3]int	//数组会初始化为int类型的零值
var numArray = [3]int{1, 2} //使用指定的初始值完成初始化
var cityArray = [3]string{"北京", "上海", "深圳"} //使用指定的初始值完成初始化
var numArray = [...]int{1, 2} //根据值推断数组长度
var cityArray = [...]string{"北京", "上海", "深圳"}

a := [...]int{1: 1, 3: 5} //指定索引初始化
fmt.Println(a) // [0 1 0 5]
for index, value := range a {
fmt.Println(index, value)
}

多维数组

a := [3][2]string{
{"北京", "上海"},
{"广州", "深圳"},
{"成都", "重庆"},
}

多维数组只有第一层可以使用...来让编译器推导数组长度

数组是值类型,赋值和传参会复制整个数组。因此改变副本的值,不会改变本身的值。

  1. 数组支持 “==“、”!=” 操作符,因为内存总是被初始化过的。
  2. [n]*T表示指针数组,*[n]T表示数组指针 。

切片

数组的局限性,长度固定。

切片(Slice)是一个拥有相同类型元素的可变长度的序列。它是基于数组类型做的一层封装。它非常灵活,支持自动扩容。

切片是一个引用类型,它的内部结构包含地址长度容量。切片一般用于快速地操作一块数据集合。

初始化

var a = []string              //声明一个字符串切片
var b = []int{} //声明一个整型切片并初始化
var c = []bool{false, true} //声明一个布尔切片并初始化
var d = []bool{false, true} //声明一个布尔切片并初始化

切片有指向时值就不为空了。

a1 := [...]int{1, 3, 5, 7, 9, 11, 13}
s3 := a1[0:4] //左包右不包,索引为0-3切片

len(s3) // 4 切片长度
cap(s3) // 7 容量=原数组切片点到末尾的长度

a[2:] // 等同于 a[2:len(a)]
a[:3] // 等同于 a[0:3]
a[:] // 等同于 a[0:len(a)]

原数组元素改了切片也变,引用类型。

a[low : high : max]
a := [5]int{1, 2, 3, 4, 5}
t := a[1:3:5] //t:[2 3] len(t):2 cap(t):4

构造与简单切片表达式a[low: high]相同类型、相同长度和元素的切片。另外,它会将得到的结果切片的容量设置为max-low。在完整切片表达式中只有第一个索引值(low)可以省略;它默认为0。

make()

动态创建一个切片

make([]T, size, cap)

a := make([]int, 2, 10) // 初始化值为0

空切片判断

要检查切片是否为空,使用len(s) == 0来判断,而不应该使用s == nil来判断。

切片之间是不能比较的,我们不能使用==操作符来判断两个切片是否含有全部相等元素。 切片唯一合法的比较操作是和nil比较。 一个nil值的切片并没有底层数组,一个nil值的切片的长度和容量都是0。但是我们不能说一个长度和容量都是0的切片一定是nil

赋值

s1 := make([]int, 3) //[0 0 0]
s2 := s1 //将s1直接赋值给s2,s1和s2共用一个底层数组
s2[0] = 100
fmt.Println(s1) //[100 0 0]
fmt.Println(s2) //[100 0 0]

append()

var s []int
s = append(s, 1) // [1]
s = append(s, 2, 3, 4) // [1 2 3 4
s2 := []int{5, 6, 7}
s = append(s, s2...) // [1 2 3 4 5 6 7]

var声明的零值切片可以在append()函数直接使用,无需初始化

var s []int
s = append(s, 1, 2, 3)

每个切片会指向一个底层数组,这个数组的容量够用就添加新增元素。当底层数组不能容纳新增的元素时,切片就会自动按照一定的策略进行“扩容”,此时该切片指向的底层数组就会更换。“扩容”操作往往发生在append()函数调用时,所以我们通常都需要用原变量接收append函数的返回值。

func main() {
//append()添加元素和切片扩容
var numSlice []int
for i := 0; i < 10; i++ {
numSlice = append(numSlice, i)
fmt.Printf("%v len:%d cap:%d ptr:%p\n", numSlice, len(numSlice), cap(numSlice), numSlice)
}
}

输出

[0]  len:1  cap:1  ptr:0xc0000a8000
[0 1] len:2 cap:2 ptr:0xc0000a8040
[0 1 2] len:3 cap:4 ptr:0xc0000b2020
[0 1 2 3] len:4 cap:4 ptr:0xc0000b2020
[0 1 2 3 4] len:5 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5] len:6 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6] len:7 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6 7] len:8 cap:8 ptr:0xc0000b6000
[0 1 2 3 4 5 6 7 8] len:9 cap:16 ptr:0xc0000b8000
[0 1 2 3 4 5 6 7 8 9] len:10 cap:16 ptr:0xc0000b8000

从上面的结果可以看出:

  1. append()函数将元素追加到切片的最后并返回该切片。
  2. 切片numSlice的容量按照1,2,4,8,16这样的规则自动进行扩容,每次扩容后都是扩容前的2倍。

$GOROOT/src/runtime/slice.go源码:

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
  • 首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)。
  • 否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap),
  • 否则判断,如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的1/4,即(newcap=old.cap,for {newcap += newcap/4})直到最终容量(newcap)大于等于新申请的容量(cap),即(newcap >= cap)
  • 如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)。

中文字符串是3*2^n

copy()

切片是引用类型,所以a和b其实都指向了同一块内存地址。修改b的同时a的值也会发生变化。

Go语言内建的copy()函数可以迅速地将一个切片的数据复制到另外一个切片空间中。

a := []int{1, 2, 3, 4, 5}
c := make([]int, 5, 5)
copy(c, a) //使用copy()函数将切片a中的元素复制到切片c
fmt.Println(a) //[1 2 3 4 5]
fmt.Println(c) //[1 2 3 4 5]
c[0] = 1000
fmt.Println(a) //[1 2 3 4 5]
fmt.Println(c) //[1000 2 3 4 5]

删除元素

a = append(a[:index], a[index+1:]...)
a := []int{30, 31, 32, 33, 34, 35, 36, 37}
// 要删除索引为2的元素
a = append(a[:2], a[3:]...)
fmt.Println(a) //[30 31 33 34 35 36 37]

//底层数组长度不变,元素左移,右边的由最右元素补全

排序对切片排

sort.Ints(a[:])

指针

ptr := &v // v的类型为T 输出指针类型*T 如 *string *int

a := 10
b := &a
fmt.Printf("a:%d ptr:%p\n", a, &a) // a:10 ptr:0xc00001a078
fmt.Printf("b:%p type:%T\n", b, b) // b:0xc00001a078 type:*int
fmt.Println(&b) // 0xc00000e018
c := *b // 指针取值(根据指针去内存取值)
fmt.Printf("type of c:%T\n", c)
fmt.Printf("value of c:%v\n", c)

&与*互补

func modify1(x int) {
x = 100
}

func modify2(x *int) {
*x = 100
}

func main() {
a := 10
modify1(a)
fmt.Println(a) // 10
modify2(&a)
fmt.Println(a) // 100
}

new与make

new函数不太常用,使用new函数得到的是一个类型的指针,并且该指针对应的值为该类型的零值

a := new(int)
b := new(bool)
fmt.Printf("%T\n", a) // *int
fmt.Printf("%T\n", b) // *bool
fmt.Println(*a) // 0
fmt.Println(*b) // false

make也是用于内存分配的,区别于new,它只用于slice、map以及chan的内存创建,而且它返回的类型就是这三个类型变量本身,而不是他们的指针类型,因为这三种类型就是引用类型

var b map[string]int
b = make(map[string]int, 10)
b["沙河娜扎"] = 100
fmt.Println(b)

map

Go语言中提供的映射关系容器为map,其内部使用散列表(hash)实现,类似python的字典

map是一种无序的基于key-value的数据结构,Go语言中的map是引用类型,必须初始化才能使用

map类型的变量默认初始值为nil,需要使用make()函数来分配内存

map[KeyType]ValueType

scoreMap := make(map[string]int, 8) // 初始化才能用,避免动态扩容!
scoreMap["张三"] = 90
scoreMap["小明"] = 100
fmt.Println(scoreMap)
fmt.Println(scoreMap["小明"])
fmt.Printf("type of a:%T\n", scoreMap)

userInfo := map[string]string{
"username": "沙河小王子",
"password": "123456",
}

判断键值是否存在

value, ok := map[key] // ok返回key是否存在的bool值

v, ok := scoreMap["张三"]
if ok {
fmt.Println(v)
} else {
fmt.Println("查无此人")
}

map的遍历

for k, v := range scoreMap {
fmt.Println(k, v)
}

for k := range scoreMap {
fmt.Println(k)
}

for _, v := range scoreMap {
fmt.Println(v)
}

注意:遍历map时的元素顺序与添加键值对的顺序无关

删除键值对

delete(map, key)

按照指定顺序遍历

func main() {
rand.Seed(time.Now().UnixNano()) //初始化随机数种子

var scoreMap = make(map[string]int, 200)

for i := 0; i < 100; i++ {
key := fmt.Sprintf("stu%02d", i) //生成stu开头的字符串
value := rand.Intn(100) //生成0~99的随机整数
scoreMap[key] = value
}
//取出map中的所有key存入切片keys
var keys = make([]string, 0, 200)
for key := range scoreMap {
keys = append(keys, key)
}
//对切片进行排序
sort.Strings(keys)
//按照排序后的key遍历map
for _, key := range keys {
fmt.Println(key, scoreMap[key])
}
}

元素为map类型的切片

var mapSlice = make([]map[string]string, 3) // 切片初始化,每个元素都是一个map
for index, value := range mapSlice {
fmt.Printf("index:%d value:%v\n", index, value)
}
fmt.Println("after init")
// 对切片中的map元素进行初始化
mapSlice[0] = make(map[string]string, 10)
mapSlice[0]["name"] = "小王子"
mapSlice[0]["password"] = "123456"
mapSlice[0]["address"] = "沙河"
for index, value := range mapSlice {
fmt.Printf("index:%d value:%v\n", index, value)
}

值为切片类型的map

func main() {
var sliceMap = make(map[string][]string, 3)
fmt.Println(sliceMap)
fmt.Println("after init")
key := "中国"
value, ok := sliceMap[key]
if !ok {
value = make([]string, 0, 2)
}
value = append(value, "北京", "上海")
sliceMap[key] = value
fmt.Println(sliceMap)
}

函数

func 函数名(参数 类型) 返回值类型 {
函数体
}

func intSum(x int, y int) int {
return x + y
}

参数同类型简写

func intSum(x, y int) int {
return x + y
}

可变参数

func intSum2(x ...int) int {
fmt.Println(x) //x是一个切片
sum := 0
for _, v := range x {
sum = sum + v
}
return sum
}

返回值

//有命名的返回
func calc(x, y int) (sum, sub int) {
sum = x + y
sub = x - y
return
}
//切片
func someFunc(x string) []int {
if x == "" {
return nil // 没必要返回[]int{}
}
...
}

如果局部变量和全局变量重名,优先访问局部变量

函数类型与变量

我们可以使用type关键字来定义一个函数类型,具体格式如下:

type calculation func(int, int) int

上面语句定义了一个calculation类型,它是一种函数类型,这种函数接收两个int类型的参数并且返回一个int类型的返回值。

func main() {
var c calculation // 声明一个calculation类型的变量c
c = add // 把add赋值给c
fmt.Printf("type of c:%T\n", c) // type of c:main.calculation
fmt.Println(c(1, 2)) // 像调用add一样调用c

f := add // 将函数add赋值给变量f1
fmt.Printf("type of f:%T\n", f) // type of f:func(int, int) int
fmt.Println(f(10, 20)) // 像调用add一样调用f
}

函数作参数与返回值

func add(x, y int) int {
return x + y
}
func calc(x, y int, op func(int, int) int) int {
return op(x, y)
}
func main() {
ret2 := calc(10, 20, add)
fmt.Println(ret2) //30
}
func do(s string) (func(int, int) int, error) {
switch s {
case "+":
return add, nil
case "-":
return sub, nil
default:
err := errors.New("无法识别的操作符")
return nil, err
}
}

匿名函数

函数内部定义函数

func main() {
// 将匿名函数保存到变量
add := func(x, y int) {
fmt.Println(x + y)
}
add(10, 20) // 通过变量调用匿名函数

//自执行函数:匿名函数定义完加()直接执行
func(x, y int) {
fmt.Println(x + y)
}(10, 20)
}

闭包

闭包指的是一个函数和与其相关的引用环境组合而成的实体。简单来说,闭包=函数+引用环境

func adder() func(int) int {
var x int
return func(y int) int {
x += y
return x
}
}
func main() {
var f = adder()
fmt.Println(f(10)) //10
fmt.Println(f(20)) //30
fmt.Println(f(30)) //60

f1 := adder()
fmt.Println(f1(40)) //40
fmt.Println(f1(50)) //90
}

defer

defer语句会将其后面跟随的语句进行延迟处理。在defer归属的函数即将返回时,将延迟处理的语句按defer定义的逆序进行执行,也就是说,先被defer的语句最后被执行,最后被defer的语句,最先被执行

func main() {
fmt.Println("start")
defer fmt.Println(1)
defer fmt.Println(2)
defer fmt.Println(3)
fmt.Println("end")
}

/*
start
end
3
2
1
*/

image-20220120184447325

//面试题 defer注册要延迟执行的函数时,该函数所有的参数都需要确定其值
func calc(index string, a, b int) int {
ret := a + b
fmt.Println(index, a, b, ret)
return ret
}

func main() {
x := 1
y := 2
defer calc("AA", x, calc("A", x, y))
x = 10
defer calc("BB", x, calc("B", x, y))
y = 20
}

/*
A 1 2 3 //defer calc("AA", 1, 3)
B 10 2 12 //defer calc("BB", 10, 12)
BB 10 12 22
AA 1 3 4
*/

内置函数

内置函数 介绍
close 主要用来关闭channel
len 用来求长度,比如string、array、slice、map、channel
new 用来分配内存,主要用来分配值类型,比如int、struct。返回的是指针
make 用来分配内存,主要用来分配引用类型,比如chan、map、slice
append 用来追加元素到数组、slice中
panic和recover 用来做错误处理

Go语言中目前(Go1.12)是没有异常机制,但是使用panic/recover模式来处理错误。 panic可以在任何地方引发,但recover只有在defer调用的函数中有效

func funcA() {
fmt.Println("func A")
}

func funcB() {
defer func() {
err := recover()
//如果程序出出现了panic错误,可以通过recover恢复过来
if err != nil {
fmt.Println("recover in B")
}
}()
panic("panic in B")
}

func funcC() {
fmt.Println("func C")
}
func main() {
funcA()
funcB()
funcC()
}
  1. recover()必须搭配defer使用。
  2. defer一定要在可能引发panic的语句之前定义。

fmt标准库

fmt包实现了类似C语言printf和scanf的格式化I/O。主要分为向外输出内容和获取输入内容两大部分

Print

func main() {
fmt.Print("在终端打印该信息。") //不换行
name := "沙河小王子"
fmt.Printf("我是:%s\n", name)
fmt.Println("在终端打印单独一行显示")
}

FPrint

Fprint系列函数会将内容输出到一个io.Writer接口类型的变量w中,我们通常用这个函数往文件中写入内容

// 向标准输出写入内容
fmt.Fprintln(os.Stdout, "向标准输出写入内容")
fileObj, err := os.OpenFile("./xx.txt", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
fmt.Println("打开文件出错,err:", err)
return
}
name := "沙河小王子"
// 向打开的文件句柄中写入内容
fmt.Fprintf(fileObj, "往文件中写如信息:%s", name)

只要满足io.Writer接口的类型都支持写入

Sprint

Sprint系列函数会把传入的数据生成并返回一个字符串

s3 := fmt.Sprintln("沙河小王子")

Errorf

e := errors.New("原始错误e")
w := fmt.Errorf("Wrap了一个错误%w", e)

Scan

fmt.Scan(&name, &age, &married)
fmt.Scanf("1:%s 2:%d 3:%t", &name, &age, &married)
fmt.Scanln(&name, &age, &married)

另有Fscan,Sscan

bufio.NewReader

func bufioDemo() {
reader := bufio.NewReader(os.Stdin) // 从标准输入生成读对象
fmt.Print("请输入内容:")
text, _ := reader.ReadString('\n') // 读到换行终止 空格也读入
text = strings.TrimSpace(text)
fmt.Printf("%#v\n", text)
}

本文作者:KANIKIG

本文链接: https://blog.kanikig.xyz/Golang%E5%9F%BA%E7%A1%80%E7%AC%94%E8%AE%B0/

评论

您所在的地区可能无法访问 Disqus 评论系统,请切换网络环境再尝试。