使用 GORM(Go 的 ORM 库)连接数据库,并完成数据持久层操作| 青训营

写在前面

本文基于字节青训营中的后端入门课程《GORM解读》,加上些许个人理解得出的课堂笔记。本人拙见,如有不当处,还望海涵。 本文需要一定的数据库知识储备。OVER!(๑╹◡╹)ノ"""

Go中的MyBatis

Gorm 是一种Go语言的数据库ORM操作库

它文档齐全,对开发者友好,支持主流数据库。主要操作是把结构体类型 和数据库表记录进行映射,并不需要手写SQL代码。

那MyBatis和它比有什么不同呢?

MyBatis并不是真正的ORM框架,只是一个半自动的SQL映射框架,提供的功能不如ORM框架强大,但是拥有更多的灵活性:

  1. MyBatis需要开发者自己编写SQL语句,因此可以充分自由地执行SQL优化;
  2. MyBatis需要开发者自己定义ResultSet与对象之间的映射关系,因此可以简单的避免循环使用的问题。

ORM,全称是Object/Relation Mapping,中文译为对象/关系映射

ORM 框架主要是根据类和数据库表之间的映射关系,帮助程序员自动实现对象与数据库中数据之间的互相转化

理解database/sql

------在Go程序中引入数据库

关系型数据库 --> 持久层制作首要选择

目标:通过统一的接口操作不同数据库

小插一嘴 (导包前的符号)

(1) 别名v2 :

相当于是导入包的一个别名,可以直接使用 v2.WxLogin来调用包中的接口或方法。

(2) 下划线_ :

表示只执行该库的 init 函数而不对其它导出对象进行真正地导入。(因为 Go 语言的数据库驱动都会在 init 函数中注册自己,所以我们只需要进行上述操作即可;否则的话,Go 语言的编译器会提示导入了包却没有使用的错误。)

(3) 点. :

表示调用这个包的函数时,可以省略前缀的包名,如fmt.Println("hello world") 可以省略的写成Println("hello world")

我们先来看下样例代码

Go 复制代码
package main  
  
import (  
  "database/sql"  
  "fmt"  
  _ "github.com/go-sql-driver/mysql"  
)  
  
func main() {  
  dsn := "root:123456@tcp(localhost:3306)/test"  
  db, err := sql.Open("mysql", dsn)  
  if err != nil {  
    panic(err)  
  }  
  defer db.Close()  
  
  // 执行查询  
  rows, err := db.Query("SELECT * FROM user")  
  if err != nil {  
    panic(err)  
  }  
  defer rows.Close()  
  
  // 处理查询结果  
  type user struct {  
    ID int  
    Name string  
  }  
  
  var users []user  
  
  for rows.Next() {  
    var u user  
    err := rows.Scan(&u.ID, &u.Name)  
    if err != nil {  
      panic(err)  
    }  
    users = append(users, u)  
  }  
  
  if rows.Err() != nil {  
    panic(rows.Err())  
  }  
  
  // 打印用户信息  
  for _, u := range users {  
    fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name)  
  }  
}

我们一条一条代码来看

L4 - database/sql包,只实现了统一的接口。不包括具体怎么连接、解析数据库。

L6 - github.com/go-sql-driver/mysql 是 Go 语言中连接 MySQL 数据库的官方驱动程序之一。它提供了与 MySQL 数据库的交互功能,允许你在 Go 代码中连接、查询和操作 MySQL 数据库。(源代码托管在 GitHub 上)

L10 - DSN是 "Data Source Name"(数据源名称)的缩写,它通常用于在代码中指定数据库连接的相关信息,如数据库类型、主机名、端口、用户名、密码等。它通常是一个字符串,可以传递给数据库连接库的函数,以便建立与数据库的连接

@tcp 是在 DSN 中用于指定使用 TCP/IP 协议进行数据库连接的一种方式。TCP/IP(Transmission Control Protocol/Internet Protocol)是一种广泛用于网络通信的协议,它能够在不同的计算机之间建立稳定的连接并进行数据传输。

许多数据库服务器支持多种连接方式,例如本地套接字(Unix Socket)或命名管道等,而 TCP/IP 协议是一种通用且可跨网络使用的连接方式。

L11 - 在驱动程序中通过 sql.Open 函数使用driver+DSN初始化DB连接。

L12~L15 - 在连接数据库的过程中进行错误处理和资源管理,确保在函数退出时关闭数据库连接并处理可能出现的错误。

  1. if err != nil { panic(err) }:这一行用于检查数据库连接是否成功建立。如果在连接数据库时出现了错误,程序会立即中断,并使用 panic 抛出错误。这是为了防止在数据库连接失败的情况下继续执行后续的代码。
  2. defer db.Close():这一行使用 defer 语句延迟执行 db.Close() 操作,以确保在函数执行完毕后会关闭数据库连接。db.Close() 用于释放与数据库连接相关的资源,包括底层的网络连接等。使用 defer 可以确保在函数返回之前一定会调用 db.Close(),无论函数是否出现了错误。

L18 - 执行一条SQL,并通过rows取回返回的数据 (这里使用的是查询全部)

L19~L22 - 每次使用数据库操作都需要释放资源

L25~L28 - 自定义结构体,对应数据库中表头

L30 - 声明一个存储 user 结构体的切片

L32 - 这是一个循环,它遍历查询结果的每一行

L33 - 在循环中,定义了一个临时的 user 变量 u,用来存储当前行的用户信息。

L34~L37 - 使用 rows.Scan 方法从查询结果的当前行中读取数据,并将读取到的 IDName 值分别存储到 u.IDu.Name 字段中。如果读取过程中出现了错误,会通过 err 变量捕获错误,并使用 panic(err) 中止程序

L38 - 将读取到的 u 用户结构体添加到 users 切片中

L41~L43 - 检查在循环读取查询结果时是否出现了错误

L46~L49 - 遍历 users 切片并打印每个用户的信息(_ 省略索引值)

可能报错

1、github.com/go-sql-driver/mysql报错可能是因为网络或者代理问题,导致proxy.golang.org无法访问

2、如果想要运行,确保你的 main.go 文件(或包含 main 函数的文件)在一个名为 main 的软件包中

设计原理

数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个。

在使用golang来处理数据库的时候,为了提升性能,往往都会使用连接池,有些人往往会自己实现一个连接池,用来互用mysql连接,但是如果你稍微细心一点, 就会发现内建的sql包已经实现了连接池。sql.Open函数实际上式返回一个连接池对象,而不是单个连接。

数据库标准接口里面有3个方法用于设置连接池的属性: SetConnMaxLifetime, SetMaxIdleConns, SetMaxOpenConns

  • SetConnMaxLifetime: 设置一个连接的最长生命周期,因为数据库本身对连接有一个超时时间的设置,如果超时时间到了数据库会单方面断掉连接,此时再用连接池内的连接进行访问就会出错, 因此这个值往往要小于数据库本身的连接超时时间
  • SetMaxIdleConns: 连接池里面允许Idel的最大连接数, 这些Idel的连接 就是并发时可以同时获取的连接,也是用完后放回池里面的互用的连接, 从而提升性能。
  • SetMaxOpenConns: 设置最大打开的连接数,默认值为0表示不限制。控制应用于数据库建立连接的数量,避免过多连接压垮数据库。

在代码使用上, 初始化db时,根据需求设置好连接池即可

scss 复制代码
var db *sql.DB
 
func init() {
    db, _ = sql.Open("mysql", "root:passwd@tcp(127.0.0.1:3306)/test?charset=utf8")
    db.SetMaxOpenConns(2000)
    db.SetMaxIdleConns(1000)
 db.SetConnMaxLifetime(time.Minute * 60)
    db.Ping()
}

db.Ping() 调用完毕后会马上把连接返回给连接池

设计原理就不展开更多了,部分源码看不懂 o(╥﹏╥)o

Grom简单实现

相比于你之前使用的 database/sqlgithub.com/go-sql-driver/mysql 驱动,使用 GORM 会有一些优势:

  1. 更高层抽象: GORM 提供了更高层次的抽象,使数据库操作更简单、更直观。它使用了方法链式调用的方式,使查询和操作数据库变得更加优雅,避免了繁琐的 SQL 语句拼写和参数绑定。
  2. 模型映射: GORM 允许你通过定义 Go 结构体来映射数据库表,使得数据模型与数据库表之间的映射变得更加直观和易于管理。你可以在结构体中使用标签来指定表名、字段名、主键等属性。
  3. 关联关系: GORM 支持定义和处理数据库表之间的关联关系,比如一对一、一对多、多对多等。这使得处理复杂的数据关系变得更加简单。
  4. 自动迁移: GORM 提供了自动迁移功能,可以根据你定义的模型自动创建或更新数据库表结构,避免手动编写 SQL 表结构。
  5. 错误处理: GORM 提供了更详细的错误信息和处理机制,使错误处理更加友好和精确。
  6. 插件和扩展: GORM 支持插件和扩展,可以轻松地扩展功能,比如支持其他数据库类型、缓存等。

还是先进入代码

Go 复制代码
package main  
  
import (  
  "fmt"  
  "gorm.io/driver/mysql"  
  "gorm.io/gorm"  
)  
  
type User struct {  
  ID int `gorm:"primaryKey"`  
  Name string  
}  
  
// TableName 在 User 结构体上使用 gorm 标签来指定表名  
func (User) TableName() string {  
  return "user" // 指定表名为 "user"  
}  
  
func main() {  
  dsn := "root:123456@tcp(localhost:3306)/test"  
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})  //打开数据库连接
  if err != nil {  
    panic(err)  
  }  
  defer db.Close()  
  
  var users []User  
  result := db.Find(&users)  
  if result.Error != nil {  
    panic(result.Error)  
  }  
  
  if len(users) == 0 {  
    fmt.Println("No users found.")  
    return  
  }  
  
  for _, u := range users {  
    fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name)  
  }  
}

认真分析一下

L21 - gorm.Open(mysql.Open(dsn), &gorm.Config{}

  1. mysql.Open(dsn):这一部分使用 GORM 的 MySQL 驱动来创建数据库连接。它使用了你提供的数据库连接字符串 dsn 来打开与 MySQL 数据库的连接。
  2. &gorm.Config{}:这是 GORM 配置的一部分,用于配置数据库连接的行为。你传递了一个空的 gorm.Config 结构体,表示你使用默认的配置选项。如果你需要自定义数据库连接的某些行为,可以在这里进行配置,比如设置日志级别、表名规则、是否自动迁移等。
  3. gorm.Open():最后,通过调用 gorm.Open() 函数,将上述两个参数传递给它,从而创建一个 GORM 数据库连接对象。这个连接对象可以用于执行数据库操作,比如查询、插入、更新等。
  4. 最后一定注意这个连接需要在不再使用时通过 defer db.Close() 来关闭,以释放资源

L28 - db.Find(&users)

使用 db.Find() 查询数据库中的所有用户记录,并将结果存储到 users 切片中

注意,这里我们发现并没有提供我们需要的是数据库中的哪个表,那么它该怎么查询呢?

在最开始方法中我们用的SQL语句指定了表,如rows, err := db.Query("SELECT * FROM user")

而这里在使用 GORM 进行查询时,你不需要直接编写 SQL 查询语句,而是通过 GORM 提供的方法链式调用来实现。这样可以更加直观地操作数据库,同时也避免了手动编写 SQL 语句的复杂性。

在 GORM 中,默认情况下,如果你没有显式指定表名,它会根据结构体名的复数形式来推断表名 。在你的示例代码中,User 结构体会被映射到一个名为 users 的表

向我们上述代码其实是会报出Table 'test.users' doesn't exist错误的,所以我们必须手动提供。

Go 复制代码
type User struct {
    ID   int    `gorm:"primaryKey"`
    Name string
}

// 在 User 结构体上使用 gorm 标签来指定表名
func (User) TableName() string {
    return "user" // 指定表名为 "user"
}

在这个示例中,使用了 TableName() 方法来指定表名为 "user"。你也可以将表名硬编码在 TableName() 方法中,或者通过参数传递来指定。

使用 gorm:"primaryKey" 标签可以将结构体字段指定为主键。GORM 将会在生成 SQL 查询时将其视为主键。这对于自动创建数据库表和执行查询操作都非常有用

当然,这么做就麻烦了,所以要遵守规范

这里的复数格式是蛇形复数(小写下划线)

Gorm基础操作

模型对应

上文我们提到了主键的关系对应 gorm:"primaryKey" ,除了这个,我们还有些其他的关系对应

  1. gorm:"column:column_name" 用于指定字段在数据库表中的列名。后面跟表中应该对应的列名
  2. gorm:"type:data_type" 用于指定字段在数据库中的数据类型。后面跟表中应该对应的列的数据结构
  3. gorm:"size:size" 用于指定字段的大小,例如字符串字段的最大长度。这可以影响数据库表的列定义。
  4. gorm:"unique" 用于指定字段在数据库表中是唯一的,即不能有重复的值。
  5. gorm:"default:default_value" 用于指定字段的默认值。一般为"galeone"
  6. gorm:"not null" 用于指定字段在数据库表中不能为空。
  7. gorm:"index" 用于指定字段应该被索引,以提高查询性能。
  8. gorm:"uniqueIndex" 用于指定字段应该被创建为唯一索引,确保值不重复。
  9. gorm:"autoCreateTime"gorm:"autoUpdateTime" 用于指定在创建和更新记录时,自动填充创建时间和更新时间字段。
  10. gorm:"associationForeignKey:foreign_key"gorm:"foreignKey:foreign_key" 用于指定关联表的外键关系。
  11. gorm:"embedded" 用于嵌入其他结构体,将其字段嵌入当前结构体。

小插一嘴(反应号的使用)

在 GORM 中,使用反引号(`)将标签放在结构体字段上,这是为了告诉 Go 编译器这是一个结构体标签(tag)。结构体标签是一种附加在结构体字段上的元数据,用于提供有关该字段的信息,比如数据库映射、验证规则等。

使用反引号包裹标签内容是为了防止在标签内容中使用特殊字符或关键字时引发错误。例如,如果你要在标签中使用保留字(如 primaryKey),则必须使用反引号来区分标签内容和保留字。

所以,gorm:"primaryKey" 中的反引号是为了确保标签被正确解析,不受 Go 语言解析规则的影响。在标签中使用反引号也是 Go 语言的一种惯例,可以防止因特殊字符或保留字而引发问题。

增加数据

Go 复制代码
//创建单条数据  
u := User{ID: 9, Name: "土豆苗"}  
if err := db.Create(&u).Error; err != nil {  
  fmt.Println("插入失败", err)  
}  
  
//创建多条数据,需要传一个数组参数。  
us := []*User{{ID: 7, Name: "海带苗"}, {ID: 8, Name: "蒜苗"}}  
if err := db.Create(&us).Error; err != nil {  
  fmt.Println("插入失败", err)  
}

先初始化一个结构体,然后调用Create函数

Go 复制代码
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&p).Error; err != nil {

错误语句更改:解决主键冲突问题

DoNothingtrue代表如果冲突的话,什么也不做(没有输出)

删除数据

删除操作可以细分

物理删除(Physical Delete): 物理删除是直接从数据库中永久删除记录的操作。执行物理删除后,数据将被完全从数据库表中删除,不再存在。物理删除是彻底清除数据,释放存储空间的一种方式。但需要注意,物理删除后数据将无法恢复,因此在执行物理删除之前需要谨慎考虑。

直接使用Delete方法

Delete函数需要传入一个结构体以及参数,如果参数是整形的话,会按主键进行删除

Go 复制代码
db.Delete(&Product{}, 1) 
db.Delete(&Product{}, "Code = ?", "F42") 
db.Delete(&Product{}, []int{1,2,3}) 
db.Where("code like ?", "%F%").Delete(Product{})

软删除(Soft Delete): 软删除是一种在数据库中保留记录,但通过某种方式标记为已删除的操作。通常是通过在记录中添加一个标识字段,如 deleted_at,来指示该记录已被删除。软删除保留了数据的历史记录,使得在需要的情况下可以恢复已删除的数据,或者进行审计和追踪。

软删除在很多情况下是更安全和更可控的删除方式,特别是在需要保留数据历史记录的应用中,如审计、版本追踪等。同时,软删除也有助于避免意外删除和数据丢失。

使用Delete 方法,并在结构体中添加 gorm:"softDelete:true" 标签

Go 复制代码
result := db.Where("name = ?", "John").Delete(&User{}) 
if result.Error != nil { 
  log.Fatal(result.Error) 
} else { 
  log.Printf("Deleted records: %d\n", result.RowsAffected) 
}

这里通过result.RowsAffected 表示数据库操作影响的行数

小插一嘴(log.Fatal)

log.Fatal(err) 是一个用于输出错误信息并终止程序运行的函数调用。在 Go 语言中,log 包提供了多种方法来输出日志信息,Fatal 方法用于输出严重的错误信息,并立即终止程序的执行。

更改数据

Go 复制代码
result := db.Model(&User{}).Where("name = ?", "John").Update("name", "Jane") 
if result.Error != nil { 
  log.Fatal(result.Error) 
} else { 
  log.Printf("Updated records: %d\n", result.RowsAffected) 
}

Gorm更新数据是通过Update函数操作的,Update函数需要传入要更新的字段和对应的值。

需要通过Model函数来传入要更新的模型,主要是用来确定表名,也可以使用Table函数来确定表名。

查询数据

Gorm通过First函数来进行数据的查询,如果要查多条数据,需要使用Find函数。Where 方法来指定查询条件

Go 复制代码
// 条件查询操作 
var users []User 
if err := db.Where("age > ?", 25).Find(&users).Error; err != nil {
  log.Fatal(err) 
} 
// 输出查询结果 
for _, user := range users {
  log.Printf("ID: %d, Name: %s, Age: %d\n", user.ID, user.Name, user.Age) 
}

如果是SQL语句则为: SELECT * FROM users WHERE age > 25;

  • 使用First查询时,如果查询不到数据会返回ErrRecordNotFound

  • 使用Find查询时,查询不到数据不会返回错误

  • 使用结构体作为查询条件时,Gorm只会查询非零值字段,也就是0''false或其他零值字段将被忽略,可以使用MapSelect来替换

事务操作

事务(Transaction)是一种用于处理数据库操作的机制,它允许你在一系列数据库操作中保持数据的一致性和完整性。事务中的一组操作要么全部成功,要么全部回滚,以确保数据库状态的正确性

创建事务

Go 复制代码
// 开始事务 
tx := db.Begin() 
if tx.Error != nil { 
  log.Fatal(tx.Error) 
} 
// 执行事务操作 
if err := tx.Model(&User{}).Where("age > ?", 25).Update("name", "Jane").Error; err != nil { 
  tx.Rollback() // 回滚事务 
  log.Fatal(err) 
}

if err := tx.Model(&User{}).Where("age > ?", 30).Update("name", "Alice").Error;err != nil { 
  tx.Rollback() // 回滚事务 
  log.Fatal(err)
} 

// 提交事务 
if err := tx.Commit().Error; err != nil { 
  tx.Rollback() // 回滚事务 
  log.Fatal(err) 
} 

log.Println("Transaction successfully committed.")

我们使用 tx.Begin() 来开始一个事务。然后,在事务中执行两个更新操作。如果其中一个操作失败,我们使用 tx.Rollback() 来回滚事务,确保数据的一致性。最后,我们使用 tx.Commit() 来提交事务,将所有操作应用到数据库中

注意:使用事务后所有操作应该使用开启事务返回的tx而不是db

使用事务函数

Transaction 函数用于创建一个数据库事务,避免用户漏写CommitRollback

Go 复制代码
// 使用 Transaction 函数进行事务操作 
err = db.Transaction(func(tx *gorm.DB) error { 
  if err := tx.Model(&User{}).Where("age > ?", 25).Update("name", "Jane").Error; err != nil { 
    return err 
  } 
  if err := tx.Model(&User{}).Where("age > ?", 30).Update("name", "Alice").Error; err != nil { 
    return err 
  } 
  return nil // 返回 nil 表示事务成功提交 
}) 

if err != nil { 
  log.Fatal(err) 
} 
log.Println("Transaction successfully committed.")

func(tx *gorm.DB) error:这是一个匿名函数或闭包,它定义了一个函数签名,接受一个 *gorm.DB 类型的参数 tx,并返回一个 error 类型。这个参数 tx 表示事务对象,你可以在函数内部使用它来执行数据库操作。

使用Hook

Hooks 是一种用于在数据库操作的不同阶段插入自定义逻辑的机制。你可以在创建、更新、删除等操作的不同阶段注册钩子函数,以便在这些操作发生时执行一些自定义代码。

Before 钩子会在某个操作执行之前触发,而 After 钩子会在某个操作执行之后触发

Go 复制代码
type User struct {
  ID uint 
  Name string 
  CreatedAt time.Time 
  UpdatedAt time.Time 
} 

func (u *User) BeforeSave(tx *gorm.DB) (err error) { 
  now := time.Now() 
  if u.CreatedAt.IsZero() { 
    u.CreatedAt = now 
  } 
  u.UpdatedAt = now 
  return nil 
} 

func main() { 
  dsn := "root:123456@tcp(localhost:3306)/test" 
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) 
  if err != nil { 
    log.Fatal(err) 
  } 
  defer db.Close() 
  
  // 注册钩子 
  db.AutoMigrate(&User{}) 
  user := User{Name: "Alice"} 
  db.Create(&user) 
}

BeforeSave 钩子被注册到 User 结构体上,用于在保存数据之前更新 CreatedAtUpdatedAt 时间戳字段。每当使用 db.Create 方法创建一个新用户时,BeforeSave 钩子会被调用,执行自定义的逻辑

AutoMigrate 是 GORM 中的一个方法,用于自动创建或更新数据库表结构,以匹配定义的 Go 结构体的字段。这个方法会根据结构体的定义,自动创建或修改数据库表的列、索引、约束等

关闭事务

但其实有时候封装在事务运行操作可能导致性能降低,这时我们就要关闭

当你设置了 SkipDefaultTransaction(跳过默认事务)true 时,GORM 将不会自动为每个查询创建事务。你需要自行处理事务的开启和提交/回滚。

在默认情况下,GORM 会使用PrepareStmt(准备语句)来执行数据库操作,这有助于防止 SQL 注入攻击并提高查询性能。通过在配置中设置为 false,你可以禁用准备语句。

Go 复制代码
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ 
  SkipDefaultTransaction: true, 
  PrepareStmt: true, 
})

错误检查

大家应该发现了,在Gorm中,错误检查变得更加简单了 而我们错误检查一般使用:

Go 复制代码
//Way是数据库增删改查操作
if err := db.Way.Error; err != nil { 
   fmt.Println("操作失败", err) 
}

在 GORM 中,许多操作返回的结果是一个 *gorm.DB 类型的对象,该对象具有一个名为 Error 的字段,用于存储操作可能发生的错误。当操作出现错误时,这个 Error 字段会被设置为相应的错误值。

为了检查操作是否产生了错误,我们可以通过以下步骤进行:

  1. 使用 db.Error 来获取操作返回的错误。这会将 *gorm.DB 对象中的 Error 字段的值赋给变量 err
  2. 检查 err 是否不等于 nil,即判断操作是否产生了错误。

点到为止

课上还讲了很多很复杂很底层的操作,暂时搞不懂,到时候再写一篇补充的文章吧 (>ω・* )ノ

也写了很多了,很感谢你看到这里٩(๑>◡<๑)۶

参考文章:

1、第1篇:MyBatis与ORM的关系 - 掘金 (juejin.cn)

2、Go ORM框架------Gorm基础教程| 青训营笔记 - 掘金 (juejin.cn)

3、golang中mysql连接池使用 - 知乎 (zhihu.com)

相关推荐
CallBack8 个月前
Typora+PicGo+阿里云OSS搭建个人图床,纵享丝滑!
前端·青训营笔记
Taonce1 年前
站在Android开发者的角度认识MQTT - 源码篇
android·青训营笔记
AB_IN1 年前
打开抖音会发生什么 | 青训营
青训营笔记
monster1231 年前
结营感受(go) | 青训营
青训营笔记
翼同学1 年前
实践记录:使用Bcrypt进行密码安全性保护和验证 | 青训营
青训营笔记
hu1hu_1 年前
Git 的正确使用姿势与最佳实践(1) | 青训营
青训营笔记
星曈1 年前
详解前端框架中的设计模式 | 青训营
青训营笔记
tuxiaobei1 年前
文件上传漏洞 Upload-lab 实践(中)| 青训营
青训营笔记
yibao1 年前
高质量编程与性能调优实战 | 青训营
青训营笔记
小金先生SG1 年前
阿里云对象存储OSS使用| 青训营
青训营笔记