想拥有服务端开发经验? 坚持 7 天带你光速上手 Golang 第二天
第一天我们已经熟悉了一些基础概念可以写提供给客户端一个接口来来通过名字预测性别了, 第二天我们学习一下写 Golang 的常见编程模式
OOP
OOP 是我们写业务的核心设计模式, Golang 是一个 “现代” 的语言,为什么打个引号呢?因为 Golang没有传统的类和继承的概念 😅,但它提供了一些机制来实现面向对象编程的特性。
对象
假设我们写一个 user 对象, 有一些属性,比如 username,password, 有一个登录方法, TS 示例如下,贴这个例子时候在感叹 TS 和 Python, Ruby 开发起应用太“快”了。。。
class User {
name: string;
age: number;
email: string;
password: string;
constructor(name: string, age: number, email: string, password: string) {
this.name = name;
this.age = age;
this.email = email;
this.password = password;
}
login(): void {
console.log(`User ${this.name} logged in.`);
}
}
那么在 golang 中如何定义一个对象呢?通过用 struct
关键字定义一个结构体,里面放一些属性,需要注意的是,放方法时候很特殊,方法不是放在结构体肚子里,而是放外面,通过一个叫做 receiver 来将一个普通方法和结构体关联在一起
type User struct {
name string
age int
email string
password string
}
func (u User) Login() {
fmt.Println("User", u.name, "logged in.")
}
Line 8 的 (u User)
就是一个 receiver,定义了这个对象后我们就可以去 new, 去实例化了。但是 golang 中不需要像 TS 那样显式的 new, 可以直接赋值并实例化
u := User{name: "Evle", age: 30, email: "evle@example.com", password: "888"}
u.Login() // 输出:User John logged in.
目前的这个 Login 方法, 只是将用户的名字输出, 不对这个实例产生影响,如果我们要提供一个编辑用户密码的方法,我们这个 Reciver 要改成 指针 即 (u *User)
func (u *User) ChangePassword(newPassword string) {
u.password = newPassword
fmt.Println("User", u.name, "changed password.")
} // 调用示例
u.ChangePassword("newpassword") // 输出:User John changed password.
为什么要改成指针呢?这涉及到 传值 和 传引用 的概念,仔细听,u User
和 u *User
的区别。这个 u
代表的是我们这个 instance, 名字是 Evle, age 是 30, 如果使用不加 * 就是传值,也就是,是一份拷贝,我们对份拷贝数据的修改不会影响这个实例
func (u User) ChangeAge(newAge int) {
u.age = newAge
所以这样修改 age 是不会生效的,因为没有修改到 这个 Evle 这个instance, 改的只是一个临时拷贝,当这个函数 scope 运行结束时,那临时修改的这个拷贝就会消失,即什么都没改。
那换成 u *User
就是传引用,也就是这个 u 就是 Evle, 对 u 的操作都会影响 Evle 这个实例,比如上面的例子修改密码
func (u *User) ChangePassword(newPassword string) {
u.password = newPassword
如果这里有对 constructor
有热爱的同学,可以自行写一个 constructor 函数来完成类的实例化
func NewUser(name string, age int, email string, password string) User
{
return User{name: name, age: age, email, password}
}
evle := NewUser("Evle", 30)
接口
还是先用 TS 举例, 我们有 2个 数据库, MySQL 和 Postgres,这两个数据库都有 连接,查询,断开 3个方法。我们要怎么写呢?我们需要定义一个 database 接口,然后让 MySQL 和 Postgres 这两个类实现这个接口,实现完我们就可以根据实际情况 new 了,一个典型的工厂模式。
interface Database {
connect(): void;
query(query: string): Result;
close(): void;
}
class MySQL implements Database {
host: string;
port: number;
username: string;
password: string;
constructor(host: string, port: number, username: string, password: string) {
this.host = host;
this.port = port;
this.username = username;
this.password = password;
}
connect(): void {
// 连接到MySQL数据库
}
query(query: string): Result {
// 执行查询并返回结果
return new Result();
}
close(): void {
// 关闭与MySQL数据库的连接
}
}
在 golang 中定义这个接口和实现这个接口呢?先定义接口用 type
type Database interface {
Connect() error
Query(query string) (Result, error)
Close() error
}
然后定义一个类来实现这个接口
type MySQL struct {
host string
port int
username string
password string
}
func (m MySQL) Connect() error {
// Connect to MySQL database
return nil
}
func (m MySQL) Query(query string) (Result, error) {
// Execute the query and return results
return Result{}, nil
}
func (m MySQL) Close() error {
// Close the connection to MySQL database
return nil
}
继承
刚才说了 golang 没有所谓的 extend 关键字去继承,它是通过简单的嵌套实现继承的,用刚才的 user 举例,我们现在想创建一个 admin 对象, 它比普通用户多一个 role 的角色,那我们可以直接定义一个 admin 的结构体,将 user 嵌套进去
type User struct {
name string
age int
email string
password string
}
func (u User) Login() {
fmt.Println("User", u.name, "logged in.")
}
type Admin struct {
User
role string
}
func main() {
admin := Admin{
User: User{
name: "Evle",
age: 30,
email: "evle@example.com",
password: "password123",
},
role: "administrator",
}
admin.Login()
fmt.Println("Admin role:", admin.role)
}
当然你也可以重写 Login 方法
func (a Admin) Login() {
fmt.Println("Admin", a.name, "logged in.")
}
并发编程
在 Golang 里面有个关键字 go
,光看名字就不平凡, go 是用来处理并发任务的,比如并发发送请求之类的。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(2)
var sharedData int
go func() {
defer wg.Done()
// 第一个并发任务
sharedData = 10
fmt.Println("第一个并发任务设置了共享数据:", sharedData)
}()
go func() {
defer wg.Done()
// 第二个并发任务
fmt.Println("第二个并发任务读取了共享数据:", sharedData)
}()
wg.Wait()
fmt.Println("并发任务执行完毕")
}
Line 14 和 Line 21 分别起了 2 个并发任务,和定义普通函数一样,前面用 go 修饰一下就可以。 Line 9,10 我们通过 sync.WaitGroup
做了一个并发控制,也就是让我们的主线程等待着(Line 27),直到 2 个并发任务都执行完,也就是每个任务 wg.Done
后,主线程再继续执行。
注意:如果在每个并发任务的匿名函数中不调用 wg.Done()方法,sync.WaitGroup将永远等待,主程序将无法继续执行。这会导致程序被阻塞住,无法正常结束。
既然是多个任务并行,那就绕不开一个问题 多任务之间如何共享数据?,我们上面的例子中是使用 共享变量来共享数据的,但是它在并发编程的场景里面需要:避免数据竞态条件,也就是说共享变量的读写顺序可能失控。那怎么做呢? 可以使用 sync.Mutex
来实现互斥锁
func main() {
var wg sync.WaitGroup
wg.Add(2)
var mu sync.Mutex
var sharedData int
go func() {
defer wg.Done()
// 第一个并发任务
mu.Lock()
sharedData = 10
mu.Unlock()
fmt.Println("第一个并发任务设置了共享数据:", sharedData)
}()
go func() {
defer wg.Done()
// 第二个并发任务
mu.Lock()
defer mu.Unlock()
fmt.Println("第二个并发任务读取了共享数据:", sharedData)
}()
wg.Wait()
fmt.Println("并发任务执行完毕")
}
在这个例子中,我们创建了一个互斥锁mu
,并在每个并发任务中使用mu.Lock()
来获取锁,使用mu.Unlock()
来释放锁。通过在访问共享变量之前获取锁,然后在访问完成后释放锁,可以确保同一时间只有一个goroutine可以访问共享变量。
需要注意的是,在获取锁之后,需要在适当的时候使用defer
语句来调用mu.Unlock()
,以确保即使在函数发生错误或提前返回时,互斥锁也能被正确地释放。
通过使用互斥锁,我们可以避免竞态条件和数据访问冲突,确保共享变量的安全访问。
除了共享变量外, golang 还提供了一个 channel 来共享数据,在本例中,通过 make(chain int)
创建一个共享数字的 channel
func main() {
var wg sync.WaitGroup
wg.Add(2)
sharedData := make(chan int)
go func() {
defer wg.Done()
// 第一个并发任务
sharedData <- 10
fmt.Println("第一个并发任务设置了共享数据:", 10)
}()
go func() {
defer wg.Done()
// 第二个并发任务
data := <-sharedData
fmt.Println("第二个并发任务读取了共享数据:", data)
}()
wg.Wait()
fmt.Println("并发任务执行完毕")
}
Line 10 把数据 10 放进 channel 中, 通过 <-
这个非常形象的符号,然后第二个任务就可以通过 data := <-sharedData
把数据取出来。通过发送和接收操作,goroutine可以安全地传递数据,并保证发送和接收的顺序。 我们提到了保证顺序,那就一定会阻塞,使用channel的发送和接收操作是同步的,也就是说发送操作会阻塞直到有接收者准备好接收数据,而接收操作也会阻塞直到有发送者准备好发送数据。每个数据只能由一个发送者发送到通道中,并且只能由一个接收者接收到,从而避免竞态条件和数据访问冲突。
CRUD
与数据库交互是后端 web 应用程序的核心,如何连接数据库呢? 以 mysql 为例
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", "username:password@tcp(localhost:3306)/database_name")
if err != nil {
panic(err.Error())
}
defer db.Close()
}
连接数据库后就可以进行增删改查操作,新增,删除,增加操作都很类似,只要通过上面的 db
连接直接执行 sql 语句即可。
func insertData(db *sql.DB) error {
_, err := db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", "John Doe", "john.doe@example.com")
if err != nil {
return err
}
return nil
}
但是查询这里稍微麻烦一点相比弱类型语言,这里需要先定义查的东西是什么数据结构,创建一个 users 数组, 里面每个用户有id, name 和 email 3个属性。rows
是一个 sql.Rows
类型的变量,它是一个结果集,包含了从数据库中查询得到的数据行。通过调用 db.Query
方法执行查询语句后,将返回一个 *sql.Rows
类型的结果集。我们可以通过 rows.Next()
方法来迭代结果集中的每一行数据,并使用 rows.Scan
方法将数据存储到指定的变量中。
type User struct {
ID int
Name string
Email string
}
func getUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, name, email FROM users")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var user User
err := rows.Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
到这里结合我们第一天内容,我们提供 4 个接口(Restful风格), 提供书籍的增删改查功能
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
_ "github.com/go-sql-driver/mysql"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
}
var db *sql.DB
func main() {
var err error
db, err = sql.Open("mysql", "username:password@tcp(localhost:3306)/database")
if err != nil {
log.Fatal(err)
}
defer db.Close()
http.HandleFunc("/books", getBooks)
http.HandleFunc("/books", addBook).Methods("POST")
http.HandleFunc("/books/{id}", updateBook).Methods("PUT")
http.HandleFunc("/books/{id}", deleteBook).Methods("DELETE")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func getBooks(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, title, author FROM books")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var books []Book
for rows.Next() {
var book Book
err := rows.Scan(&book.ID, &book.Title, &book.Author)
if err != nil {
log.Fatal(err)
}
books = append(books, book)
}
jsonData, err := json.Marshal(books)
if err != nil {
log.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%s", jsonData)
}
func addBook(w http.ResponseWriter, r *http.Request) {
var book Book
err := json.NewDecoder(r.Body).Decode(&book)
if err != nil {
log.Fatal(err)
}
_, err = db.Exec("INSERT INTO books (title, author) VALUES (?, ?)", book.Title, book.Author)
if err != nil {
log.Fatal(err)
}
w.WriteHeader(http.StatusOK)
}
func updateBook(w http.ResponseWriter, r *http.Request) {
var book Book
err := json.NewDecoder(r.Body).Decode(&book)
if err != nil {
log.Fatal(err)
}
_, err = db.Exec("UPDATE books SET title=?, author=? WHERE id=?", book.Title, book.Author, book.ID)
if err != nil {
log.Fatal(err)
}
w.WriteHeader(http.StatusOK)
}
func deleteBook(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
_, err := db.Exec("DELETE FROM books WHERE id=?", id)
if err != nil {
log.Fatal(err)
}
w.WriteHeader(http.StatusOK)
}
常用包
使用 Go 开发 Web 应用常见的一些包比如
- net/http
- html/template
- encoding/json
- database/sql
- ...
html/template
自从前后端分离后也不怎么通过服务端渲染页面,其他的包我们大概都用过,这里在介绍一些常用的,铺垫一下后面开发真实系统中会用到
加解密
我们做一个登录,当密码传过来以后我们通常会把他加密存到数据库里,在这个场景中,当用户注册时,他们的密码将被使用AES加密算法加密,并将加密后的密码存储在数据库中。当用户登录时,他们输入的密码将使用相同的AES密钥进行加密,并与数据库中存储的加密密码进行比较。如果两个加密后的密码匹配,用户将被授权访问他们的账户。
通过使用AES加密,即使数据库被黑客攻击或泄露,未经授权的人员也无法轻易获得用户的真实密码。这提供了额外的安全层,保护了用户的账户和个人信息。,这里我们使用 AES 对称加密来保护用户用户的密码
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
)
func main() {
key := []byte("example-key-1234") // 16, 24, or 32 bytes
plaintext := []byte("Hello, World!")
// 使用密钥创建一个新的AES密码块
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
// 使用AES密码块创建一个新的GCM(Galois/Counter Mode)密码
gcm, err := cipher.NewGCM(block)
if err != nil {
panic(err)
}
// 生成一个随机的nonce(一次性使用的数字)
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(err)
}
// 使用GCM密码和nonce加密明文
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// 使用GCM密码和nonce解密密文
decrypted, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
panic(err)
}
fmt.Printf("明文: %s\\n", plaintext)
fmt.Printf("密文: %x\\n", ciphertext)
fmt.Printf("解密后: %s\\n", decrypted)
}
在加密中,nonce(number used once)是一种一次性使用的随机数。它在加密算法中的作用是确保每次加密操作生成的密文都是唯一的,即使使用相同的密钥和明文。这样可以增加密码的安全性,防止攻击者通过分析重复的密文来破解加密算法。
在AES-GCM模式中,nonce是用于生成初始计数器(initial counter)的输入。初始计数器用于生成密文,以及在解密时验证密文的完整性和真实性。由于nonce是一次性使用的,每次加密时都会生成一个新的nonce,并与密文一起传输。解密时,接收方使用相同的nonce和密钥来还原初始计数器,并验证密文的完整性。
由于nonce是公开传输的,所以攻击者可以看到nonce的值。然而,由于每次加密都使用不同的nonce和初始计数器,攻击者无法通过观察nonce来推断密钥或破解加密算法。
MD5 算法已经不再被认为是安全的密码算法了,但是以前通常都是把用户密码 md5 一下,存到库里,这里也代码示例一下
package main
import (
"crypto/md5"
"encoding/hex"
"fmt"
)
func main() {
text := "用户密码"
hash := md5.Sum([]byte(text))
md5Hash := hex.EncodeToString(hash[:])
fmt.Println(md5Hash)
}
Strings
这里只列了一些使用例子,其他使用也大同小异比如 trim 之类的操作,直接点开 strings 包找相关的方法如果遇到字符串处理需求的时候。
// 字符分割
input := "apple,banana,orange"
result := strings.Split(input, ",")
// 字符串拼接
str1 := "Hello"
str2 := "World"
result := str1 + " " + str2
// 字符串是否包含,返回 true, false
input := "Hello World"
result := strings.Contains(input, "World")
// 大小写转换
input := "Hello World"
result := strings.ToLower(input)
结尾
如果本文对你有帮助,点个赞收藏慢慢练习把,本系列的核心是练习,没有列出一些常见语法,因为语法不需要额外的单独学习,经常写就记忆了。
转载自:https://juejin.cn/post/7282950171008548864