Go 语言学习

Go 语言学习

数组

以数组为参数函数中不会修改原始数组的值

数组是可以使用 == 进行比较的,数组类型、长度要保持一致

切片

函数中会影响到传入切片原始值

创建

  1. var Slice []int

    默认为空长度为0

  2. Slice := []int{1,2}

    默认为空长度为0
    
    
  3. Slice := make([]int, 5 ,10)

    已经初始化长度为5,容量为10即已开辟的空间 用cap查看容量
    
    

  1. 使用下标

    Slice [1] = 1

  2. 使用append

    Slice := make([]int, 5, 6)
    Slice = append(Slice, 1, 2, 3, 4, 5, 6, 7, 8, 9)
    

    append 是将新元素追加到末尾,如果容量超了会进行扩容

Slice := []int{1, 2, 3, 4, 5, 6, 7, 8}
fmt.Println(Slice[1:5:6]) //1 起始位置、5 截止位置、6 容量,切片最多能容纳多少

截取的新切片是拿到的指向原始切片部分的地址,修改新切片会影响原来切片的值

copy

Slice1 := []int{1, 2}
Slice2 := []int{3, 4, 5}
copy(Slice1, Slice2) // 将2中的内容拷贝到1,多余的丢弃

算法

冒泡排序

思路:先写一次循环称循环1找到将最大值放到最后一个,再将循环执行循环1的操作即套在循环1外面称循环2,循环2每执行一次需要排序的元素就少一个,所以修改循环1的次数为,切片长度减去循环2的次数即完成冒泡排序。

Slice1 := []int{8, 9, 7, 6, 5, 4, 3, 2, 1, 0}
for x := 1; x < len(Slice1); x++ { // 循环2
    for i := 0; i < len(Slice1)-x; i++ { // 循环1 
        if Slice1[i] > Slice1[i+1] {
            tmp := Slice1[i]
            Slice1[i] = Slice1[i+1]
            Slice1[i+1] = tmp
        }
    }
    fmt.Println(Slice1)
}

Map

无序的键值对的集合

函数中会影响到传入的Map原始值

创建

  1. var m1 map[string]string

    var m1 map[string]string
    m1 = map[string]string{"zhangsan": "123"}
    
  2. m2 := map[string]string{"zhangsan": "123"}

  3. m3 := make(map[string]string)

    m3 := make(map[string]string)
    m3["张三"] = "123"
    

m1 := map[string]string{"hangman": "123"}
value, ok := m1["zhangsan"]
if ok {
   fmt.Println(value)
} else {
   fmt.Println("不存在")
}

m1 := map[string]string{"hangman": "123"}
delete(m1, "hangman")

结构体

函数中不会影响到传入的结构体原始值

结构体定义在函数外部并首字母大写可被其他包调用

创建

type User struct {
    age  int
    name string
}

user1 := User{18, "zhangsan"}
fmt.Println(user1)

user2 := User{
    age:  19,
    name: "lisi",
}
fmt.Println(user2)

var user3 User
user3.age = 20
user3.name = "wangwu"
fmt.Println(user3)

如果想在结构体的方法中修改值,应该使用结构体指针

type User struct {
    age  int
    name string
}

func (user *User) EditInfo() { // 通过指针类型才能将其值修改
    user.age = 55
}

方法值与方法表达

.的优先级大于*

type User struct {
    age  int
    name string
}

func (user *User) EditInfo(age int) {
    user.age = age
}

func main() {

    user1 := User{18, "zhangsan"}
    //f := user1.EditInfo // 方法值
    //f(22)
    //fmt.Println(user1)
    f := (*User).EditInfo // 方法表达式,.的优先级 大于* 所以用()包起来
    f(&user1, 55)
    fmt.Println(user1)

}


接口

空接口

不包含任何的方法,所有类型都实现了空接口,所以空接口可以存储任意类型的数值

应用场景:一般函数传参不知道会接收什么类型的参数时候使用空接口当作参数

多态性

type Persion interface {
    SayHello()
}

type Student struct {
}

func (Student *Student) SayHello() {
    fmt.Println("老师好")
}

type Teacher struct {
}

func (teacher *Teacher) SayHello() {
    fmt.Println("学生好")
}

func WhoSayHi(h Persion) { //多态性
    h.SayHello()
}

func main() {

    var stu Student
    var teacher Teacher
    WhoSayHi(&stu) // 只有Student全实现了Persion中方法才能这样用
    WhoSayHi(&teacher)

}

类型断言

通过类型断言判断空接口中存储的数据类型

语法

value, ok = m.(T)
// m:表示空接口类型的变量
// T:是断言的类型
// value:变量m中的值
// ok:布尔类型变量,如果断言成功为true,否则为false
    type Student struct {
    	id   int
    	name string
    }


    stu := Student{
    	id:   0,
    	name: "zhangsan",
    }
    var i interface{}
    i = stu
    student, ok := i.(Student)
    if ok {
    	fmt.Println(student)
    }

指针

指针是保存着内存地址

new 函数

根据数据类型开辟对应的内存空间 ,返回值为数据类型执行

var p *int
p = new(int)
*p = 57

异常

panic

引发异常,从而强制终止整个程序的执行,一般系统内部调用,程序员不会调用

error

自定义异常 errors.New("错误")

recover

与defer连用,即使函数执行过程中发生异常也不会抛出panic异常

文件操作

// 创建文件,返回文件句柄指针和异常信息
    //file, err := os.Create("log.txt")
    // 打开文件,只读模式
    //file, err := os.Open("log.txt")
    // 打开文件, 第二个参数为打开模式, 第三个参数为打开权限
    file, err := os.OpenFile("log.txt", os.O_APPEND, 6)
    if err != nil {
    	fmt.Println(err)
    	return
    }
    // 关闭
    defer file.Close()

    //写入数据字符串类型,返回长度
    //strLen, err := file.WriteString("hello World")
    // 以byte类型写入
    //strLen, err := file.Write([]byte("hello World"))
    // 获取文件原始内容结尾的位置
    //seek, err := file.Seek(0, io.SeekEnd)
    // 从某个位置开始写入
    //strLen, err := file.WriteAt([]byte("hello World"), seek)
    //if err != nil {
    //	fmt.Println(err)
    //}
    //fmt.Println(strLen)

    //文件读取
    //buffer := make([]byte, 1024*2) //读取2kb
    //n, err := file.Read(buffer)
    //if err != nil {
    //	fmt.Println(err)
    //}
    //fmt.Println(string(buffer[:n]))

    //循环读取
    //防止文件太大,buffer读取不完整,使用循环读取,读取到文件结尾会返回io.EOF错误
    buffer := make([]byte, 10)
    for {
    	n, err := file.Read(buffer)
    	if err == io.EOF {
    		break
    	}
    	fmt.Print(string(buffer[:n]))
    }
    

字符串常用处理方法

strings

  1. strings.HasPrefix(s string, prefix string) bool,判断字符串s是否以prefix开头
  2. strings.HasSuffix(s string, suffix string) bool,判断字符串s是否以suffix结尾
  3. strings.Index(s string, str string) int,判断str在s中首次出现位置,如果没有出现。则返回-1
  4. strings.LastIndex(s string, str string) int,判断str在s中最后出现位置,如果没有出现。则返回-1
  5. strings.Replace(str string, old sting, new string, n int) 在str中将n个old字符替换为new字符
  6. strings.Count(str string, substr string) int,统计substr在str中出现的次数
  7. strings.Repeat(str string, count n) string,重复cont次str
  8. strings.ToLower(str string) string,将str字符转换为大写
  9. strings.ToUpper(str string) string,将str字符转换为小写
  10. strings.TrimSpace(str string) 去掉字符串首尾空白字符
  11. strings.Trim(str string, cut string) 去掉字符串首尾cut字符
  12. strings.TrimLeft(str string, cut string) 去掉字符串首cut字符
  13. strings.TrimRight(str string, cut string) 去掉字符串首cut字符
  14. strings.Fields(str string) 以空格split
  15. strings.Split(str string, sep) 使用sep字符将str分割
  16. strings.Join(s1 [] string, sep string) 使用sep把s1中的所有元素连接起来

strconv

  1. strconv strconv.Itoa(i int) 将int类型i转化为字符型
  2. strconv.Atoi(s string) 将string类型s转化为int类型

并发理解

进程并发、线程并发、协程并发

并行:同一时刻,多条指令在多个处理器上同时执行。(多核)

并发:同一时刻只能由一条指令,但多个指令被快速轮换执行

进程状态:初始态(变量初始化、内存申请等)、就绪态(等待cpu分配时间)、运行态(占用cpu)、挂起态(阻塞、主动放弃cpu,等待其他资源到来)、终止态

线程并发:以线程为单位进行争夺cpu

同步:协调步调、规划先后顺序,即多个控制流共同操作一个共享资源的情况,都需要同步。

同步机制:互斥锁

同步的方式:读写锁

写独占、读共享

协程(coroutine):轻量线程,在阻塞时间执行其他事情,不进行死等

进程:进程稳定,一个进程结束不会影响其他进行,但是开销大

线程:节省资源

协程:效率高

Go并发

runtime.Gosched() 出让cpu

runtime.NumCPU()返回 CPU 逻辑核数

runtime.GOMAXPROCS(8)设置当前进程使用最大CPU核数

channel

先进先出

make(chan type) 无缓冲,不做数据存储,读写同步

make(chan type, capacity) 有缓冲,缓存区村粗数据,容量满了后会进行阻塞,异步

ch := make(chan int) //默认是双向的
//ch := make(chan<- int) //单向写
//ch := make(<-chan int) //单向读
ch <- 1 // 写
num := <-ch //读
close(ch) //关闭,
if num,ok := num; ok == true{} // 判读对端通道是否关闭,该可用range代替
  • 关闭channal 后,有缓冲channal 还可以进行读取缓冲中数据,读完后读到值是0
  • 关闭channal 后,无缓冲channal 读到值是0

生产者消费者模型

缓冲区:

  1. 解耦(降低生产者、消费者之间耦合度)
  2. 并发(生产消费和数量不对等,能保持正常通信)
  3. 缓存(生产者和消费者 数据处理速度不一致时,暂存数据)

定时器

timer := time.NewTimer(time.Second) //创建定时器,指定定时时长
timer.Reset(time.Second * 2)        // 定时器重置时间
c := <-timer.C                      // 定时满,系统自动写入系统时间,当stop后执行该操作会进行阻塞
fmt.Println(c)

//周期性定时器,可重复执行
ticker := time.NewTicker(time.Second)
for {
    now := <-ticker.C
    fmt.Println(now)
}


d := <-time.After(time.Second)
fmt.Println(d)

select

用来监听channel上的数据流动方向,读、写

  1. case 后面必须是io操作,不可以任意写别的判断表达式
  2. 监听的case中,没有满足条件的,阻塞
  3. 监听的case中,有多个满足条件的,任选一个执行、
  4. 可以使用default来处理所有case都不满足的条件状况。不推荐!
  5. select自身不带有循环机制,需要借助外层for循环监听
  6. break只能跳出break。
  7. 可使用<-time.After(time.Second) 进行超时判断
c := make(chan int)
q := make(chan bool)

go func() {
    select {
        case num := <-c:
        fmt.Println(num)
        case <-time.After(time.Second):
        fmt.Println("timeout")
        q <- true
        break
    }
}()
//c <- 1
<-q

死锁

  1. channel 应该在至少2个以上的go程中通信。否则死锁,要不读阻塞,不要就写阻塞
  2. 使用channel一段读(写),要保证另一端写(读),同时有机会执行
  3. 多个channel交叉死锁,Ago程掌握M同时拿N,Bgo程掌握N同时拿M

互斥锁

  1. A、Bgo程 共同访问共享数据。由于cpu电镀随机,需要对共享数据访问顺序加以限定(同步)
  2. 创建mutex(互斥锁),访问共享数据前,加锁,访问结束,解锁。Ago程枷锁期间,Bgo程加锁会失败进行阻塞,直至A Go程解锁,B从阻塞中恢复执行

读写锁

  1. 读时共享,写时独占。写锁优先级比读锁高

socket 编程

  • 双向半双工:既可以读也可以写,但是读的时候只能读,写的时候只能写(例:对讲机)
  • 双向全双工:既可以读也可以写(例:电话)
  • 单工通信:(例:遥控器)

socket 双向全双工

网络应用设计模式:

C/S:
	优点:数据传输效率高(部分数据已经缓冲在本地,例如图片等)、协议选择灵活

缺点:工作量大需要多些个c端
B/S: 优点:开发工作较小、不受平台限制
缺点:缓存数据差、协议选择不灵活、

操作Mysql

通过db,_ = sql.Open(驱动名,数据源)对数据源进行配置,

db.Ping()进行数据库连接

db.Exec(sql) 执行SQL语句(增删改)

db.QueryRow()单行查询,查多个结果的SQL语句也只显示第一条

db.Query()查询多行,配合rows.Next()取所有值

db.Prepare(sql)对SQL语句进行预处理,反货stmt可进行增删改查

db, _ := sql.Open("mysql", "blog:blog@(127.0.0.1:3306)/blog")
defer db.Close()
err := db.Ping()

if err != nil {
fmt.Println("数据库连接异常:", err)
}

insertSql := `Insert into news values (3, "content","title")`

result, _ := db.Exec(insertSql)
fmt.Println(result.RowsAffected()) // 获取受影响行数

news := [2][3]string{{"4", "content4", "title4"}, {"5", "content5", "title5"}}



//var id, content, title string
//rows := db.QueryRow("select * from news")
//rows.Scan(&id, &content, &title)
//fmt.Printf("ID: %s\nTitle: %s\nContent: %s", id, title, content)

var id, content, title string
rows, err := db.Query("select * from news")

for rows.Next() {
    rows.Scan(&id, &content, &title)
    fmt.Printf("ID: %s\nTitle: %s\nContent: %s", id, title, content)
}

stmt, _ := db.Prepare(`Insert into news values (?, ?,?)`)

for _, n := range news {
    stmt.Exec(n[0], n[1], n[2])
}

Beego

MVC: 

​	C: controller 控制器

​	M: Model 模型 数据相关

​	V: View 实图

客户端请求Controller,Controller找Model拿数据,拿到数据将经过处理通过View生成可展示的页面,最后在中将页面返回。

安装beego

go get -u github.com/astaxie/beego

安装 Bee工具

go get -u github.com/beego/bee

Bee 用来操作创建项目、运行beego等

bee new projectName  # 创建项目
bee run # 运行项目 

mac 运行报错执行 go get -u golang.org/x/sys

如果设置了TplName,运行还报错Handler crashed with error can't find templatefile in the path:views/xxx/xxx.tpl,是否是指针 例: func (c *UserController) ShowReg() {

路由

语法:beego.Router(请求路径, 处理方法指针, 请求方法对应函数)

beego.Router("/", &controllers.MainController{})
// 一个请求指定1个方法(get请求是1种方法,post请求是另一种方法)在controller中有ShowIndex和HandleFunc函数
beego.Router("/index", &controllers.IndexController{}, "get:ShowIndex;post:HandleFunc")
// 多个请求指定1个方法
beego.Router("/index", &controllers.IndexController{}, "get,post:ShowIndex")
// 所有请求指定1个方法
beego.Router("/index", &controllers.IndexController{}, "*:ShowIndex")
// 当两种指定方法冲突时,谁的范围小谁的优先级就高
beego.Router("/index", &controllers.IndexController{}, "*:ShowIndex")

ORM

O 对象 R关系 M映射

表的创建

package models

import (
    "github.com/beego/beego/v2/client/orm"
    _ "github.com/go-sql-driver/mysql"
    "time"
)

// User 用户表
type User struct {
    Id       int
    Name     string
    PassWord string
    Articles []*Article `orm:"reverse(many)"` // 多对多反向
}

// Article 文章内容表
type Article struct {
    Id       int       `orm:"pk;auto"`
    ArtiName string    `orm:"size(20)"`
    Atime    time.Time `orm:"auto_now"`
    Acount   int       `orm:"default(0);null"`
    Acontent string    `orm:"size(500)"`
    Aimg     string    `orm:"size(100)"`

    ArticleType *ArticleType `orm:"rel(fk)"`  // foreign key 一对多
    Users       []*User      `orm:"rel(m2m)"` // 多对多 //记录浏览用户
}

// ArticleType 文章类型表
type ArticleType struct {
    Id       int
    TypeName string     `orm:"size(20)"`
    Articles []*Article `orm:"reverse(many)"`
}

func init() {
    orm.RegisterDriver("mysql", orm.DRMySQL)
    // 获取连接对象
    // 参数4(可选)  设置最大空闲连接
    // 参数5(可选)  设置最大数据库连接 (go >= 1.2)
    orm.RegisterDataBase("default", "mysql", "beestudy:beestudy@(127.0.0.1:3306)/beestudy?charset=utf8")
    // 创建表
    orm.RegisterModel(new(User), new(Article), new(ArticleType))
    // 生成表
    //自动建表功能在非 force 模式下,是会自动创建新增加的字段的。也会创建新增加的索引。
    //对于改动过的旧字段,旧索引,需要用户自行进行处理。
    // 第一个参数是数据库别名,第二个参数drop table 后再建表(会丢失数据),第三个参数SQL语句是否为可见
    orm.RunSyncdb("default", false, true)
}

最新版本beego v2 创建多对多关系表时报错 panic: reflect: call of reflect.Value.MethodByName on zero Value

数据库创建无问题,是v2的一个bug.

终端中将beego v2升级 go get -u github.com/beego/beego/v2@master

然后

go mod tidy

给字段添加属性,需要在这个字段后面添加 `` 括起来的内容,格式为orm:"限制条件"

| 限制条件 | 作用 | | :--------------------: | :----------------------------------------------------------: | | pk | 设置该字段为主键 | | auto | 这只该字段自增,但是要求该字段必须为整型 | | default(0) | 设置该字段的默认值,需要注意字段类型和默认值类型一致 | | size(100) | 设置该字段长度为100个字节,一般用来设置字符串类型 | | null | 设置该字段允许为空,默认不允许为空 | | unique | 设置该字段全局唯一 | | digits(12);decimals(4) | 设置浮点数位数和精度。比如这个是说,浮点数总共12位,小数位为四位。 | | auto_now | 针对时间类型字段,作用是保存数据的更新时间 | | auto_now_add | 针对时间类型字段,作用是保存数据的添加时间 |

注意:当模型定义里没有主键时,符合int, int32, int64, uint, uint32, uint64 类型且名称为 Id 的 Field 将被视为主键,能够自增. "

Mysql中时间类型有date和datetime两种类型,但是我们go里面只有time.time一种类型,如果项目里面要求精确的话,就需要指定类型,指定类型用的是type(date)或者type(datetime)

表的操作

创建好表之后就可以直接使用ORM进行操作了

在controller中调用

o := orm.NewOrm()
// 增
user := models.User{
    Id:       1,
    Name:     "张三",
    PassWord: "abcd@123",
}
count, err := o.Insert(&user)
if err != nil {
    logs.Error("插入失败")
}
logs.Info(count)

// 查
user2 := models.User{Id: 1}
err := o.Read(&user2, "Id")
if err != nil {
    logs.Error("查询失败")
}

// 高级查询
qs := o.QueryTable("Article") // 返回queryseter
var article []models.Article
// 查询所有数据
count, err := qs.All(&article)
if err != nil {
    logs.Error("查询数据错误")
}


// 改
user3 := models.User{Id: 1}
user3.Name = "李四"
user3.PassWord = "Aa111111"
o.Update(&user3)

// 删
user4 := models.User{Id: 1}
count, err := o.Delete(&user4)
if err != nil {
    logs.Error("删除异常")
}
logs.Info(count)

视图

{{ .msg }} 获取变量方式

循环

{{range $index,$val := .articles}}
        {{$val}}        
{{end}}

$index表示的是下标,$val表示的数组元素,循环的内容放在range和end之间。

另外一种循环如下:

{{range .articles}}
    {{.Name}}
{{end}}

在range和end之间通过{{.}}直接获取数组元素的字段值。

判断

{{ if compare .userName "" }} xxx {{ else }} xxx {{ end }}

Session

配置文件sessionon = true

// FilterUser 过滤器
var FilterUser = func(ctx *context.Context) {
    userName := ctx.Input.Session("userName") // 获取session
    if userName == nil {
    	ctx.Redirect(302, "/login")
    	return
    }
}

在setSession 时报错Handler crashed with error runtime error: invalid memory address or nil pointer dereference, 大概率是因为没有开启 sessionon = true

Controller中

c.SetSession("userName", userName)
c.GetSession("userName")

Redis

安装 go get github.com/gomodule/redigo/redis

建立连接

package main

import (
    "fmt"
    "github.com/gomodule/redigo/redis"
)

func main() {
    conn, _ := redis.Dial("tcp", "127.0.0.1:6379") // 建立连接
    defer conn.Close()                             // 关闭
    err := conn.Send("set", "test1", "1")          // 写入到缓冲

    err = conn.Flush() // 写入redis
    if err != nil {
    	fmt.Print("写入错误")
    }
    reply, err := conn.Receive()
    if err != nil {
    	fmt.Print("读取错误")
    }
    fmt.Print(reply)
}

//redis
//127.0.0.1:6379> keys *
//test1
//127.0.0.1:6379> get test1
//1

执行redis命令

package main

import (
    "fmt"
    "github.com/gomodule/redigo/redis"
)

func main() {
    conn, _ := redis.Dial("tcp", "127.0.0.1:6379") // 建立连接

    reply, err := redis.String(conn.Do("get", "test1")) //redis.String 以对应的类型接收返回
    if err != nil {
    	fmt.Print("执行错误")
    }
    fmt.Print(reply)
}

// 1
package main

import (
    "fmt"
    "github.com/gomodule/redigo/redis"
)

func main() {
    conn, _ := redis.Dial("tcp", "127.0.0.1:6379") // 建立连接

    reply, err := redis.Values(conn.Do("mget", "test1", "test2"))
    if err != nil {
    	fmt.Print("执行错误")
    }
    var age int
    var name string
    redis.Scan(reply, &age, &name)
    fmt.Print(age, "\t", name)
}
//1	张三

序列化与反序列化

自定义结构体是不能保存到redis中的,所以使用序列化/反序列化将数据转为bytes类型保存

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "github.com/gomodule/redigo/redis"
)

type User struct {
    Name string //首字母要大写,否则会报错gob: type xx.Xxx has no exported fields
    Age  int
}

func main() {

    var user User
    user.Name = "张三"
    user.Age = 23
    var buffer bytes.Buffer        //容器
    enc := gob.NewEncoder(&buffer) //编码器
    err := enc.Encode(&user)       //编码
    if err != nil {
    	fmt.Println("编码失败")
    	fmt.Println(err)
    	return
    }

    conn, _ := redis.Dial("tcp", "127.0.0.1:6379") // 建立连接
    reply, err := redis.String(conn.Do("set", "test3", buffer.Bytes()))
    if err != nil {
    	fmt.Print("执行错误")
    }

    fmt.Println(reply)

    var u2 User
    reply2, err := redis.Bytes(conn.Do("get", "test3"))
    dec := gob.NewDecoder(bytes.NewReader(reply2)) //解码器
    dec.Decode(&u2)                                //解码

    fmt.Println(u2)
}

//redis
//127.0.0.1:6379> get test3
//#��User��Name
//��张三.      Age

Go 操作Redis集群

go get github.com/gitstliu/go-redis-cluster

func (this*ClusterController)Get(){
    cluster, _ := redis.NewCluster(
    	&redis.Options{
    		StartNodes: []string{"192.168.110.37:7000", "192.168.110.37:7001", "192.168.110.37:7002","192.168.110.38:7003","192.168.110.38:7004","192.168.110.38:7005"},
    		ConnTimeout: 50 * time.Millisecond,
    		ReadTimeout: 50 * time.Millisecond,
    		WriteTimeout: 50 * time.Millisecond,
    		KeepAlive: 16,
    		AliveTime: 60 * time.Second,
    	})

    cluster.Do("set","name","zhangsan")

    name,_ := redis.String(cluster.Do("get","name"))
    beego.Info(name)
    this.Ctx.WriteString("集群创建成功")
}

文章作者: 子杰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 子杰 ! !
评论
 上一篇
dnslog 平台搭建记录 dnslog 平台搭建记录
那么DNSlog是什么。DNSlog就是存储在DNS服务器上的域名信息,它记录着用户对域名baidu.com等的访问信息,类似日志文件。主要利用场景有SQL盲注、无回显的命令执行、无回显的SSRF等
2023-07-18
下一篇 
常见浏览器密码分析及Golang编写密码抓取工具 常见浏览器密码分析及Golang编写密码抓取工具
80版本之前的Chrome、80版本之后的Chrome、360安全浏览器等密码加密分析,Golang编写密码抓取工具。
2023-10-07
  目录