golang web学习随便记4-内存、文件、数据库

我们来开始学习如何存储数据。书中有一点不错,就是并不是一上来就告诉你存储数据使用数据库,因为不同的数据存储适合不同的手段。

用内存存储数据

先来看在内存中存储数据:下面的例子用结构体方式在内存存放数据,然后利用两个map来表示“索引”,键值对中的值是指向内存中结构体实例的指针。以下main函数的主要步骤是,用make初始化两个索引用的map,生成数据存放到结构体实例中,调用store创建索引,验证两种索引方式

package main

import "fmt"

type Post struct {
	Id      int
	Content string
	Author  string
}

var PostById map[int]*Post
var PostsByAuthor map[string][]*Post

func store(post Post) {
	PostById[post.Id] = &post
	PostsByAuthor[post.Author] = append(PostsByAuthor[post.Author], &post)
}

func main() {
	PostById = make(map[int]*Post)
	PostsByAuthor = make(map[string][]*Post)

	post1 := Post{Id: 1, Content: "你好, Golang", Author: "张三"}
	post2 := Post{Id: 2, Content: "你好, C++", Author: "李四"}
	post3 := Post{Id: 3, Content: "你好, Java", Author: "王五"}
	post4 := Post{Id: 4, Content: "你好, C", Author: "张三"}
	store(post1)
	store(post2)
	store(post3)
	store(post4)

	fmt.Println(PostById[1])
	fmt.Println(PostById[3])

	for _, post := range PostsByAuthor["张三"] {
		fmt.Println(post)
	}
	for _, post := range PostsByAuthor["李四"] {
		fmt.Println(post)
	}

}

go  run  . 运行后输出如下:

sjg@sjg-PC:~/go/src/memory_store$ go run .
&{1 你好, Golang 张三}
&{3 你好, Java 王五}
&{1 你好, Golang 张三}
&{4 你好, C 张三}
&{2 你好, C++ 李四}

正如书中所说,这个例子非常简单,但是,在实际应用中,对于需要在内存中缓存数据来提升性能的场合,并非都要用redis那样厚重的外部内存数据库,或许我们简单构建一下内存数据存储就能很好解决问题。

用文件存储数据

读写文本文件

用Golang读写字节数组数据并不复杂,而且和PHP类似,既可以一次性直接将数据写入文件或者从文件读取数据,也可以先创建或者打开文件,再读写数据

2024.6.6 注:新一点的golang版本中,应该用 os.ReadFile/os.WriteFile 取代 ioutil.ReadFile/ioutil.WriteFile

package main

import (
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	data := []byte("欢迎使用 Golang 编程语言\n")
	err := ioutil.WriteFile("datafile1", data, 0644) // 直接写入字节数组数据到文件
	if err != nil {
		panic(err)
	}
	readBuf1, _ := ioutil.ReadFile("datafile1") // 直接读取文件数据到缓冲字节数组
	fmt.Print(string(readBuf1))

	file1, _ := os.Create("datafile2")
	defer file1.Close()

	byteCnt, _ := file1.Write(data) // 创建文件再写入字节数组数据
	fmt.Printf("写入 %d 字节到文件 datafile2\n", byteCnt)

	file2, _ := os.Open("datafile2")
	defer file2.Close()

	readBuf2 := make([]byte, len(data))
	byteCnt, _ = file2.Read(readBuf2) // 打开文件再读取数据到缓冲字节数组
	fmt.Printf("从文件 datafile2 读取 %d 字节\n", byteCnt)
	fmt.Println(string(readBuf2))
}

运行结果如下(注意观察在项目目录下生成的数据文件datafile1和datafile2)

sjg@sjg-PC:~/go/src/file_store1$ go run .
欢迎使用 Golang 编程语言
写入 33 字节到文件 datafile2
从文件 datafile2 读取 33 字节
欢迎使用 Golang 编程语言

读写CSV

在各种应用中,CSV是非常常用的数据格式,golang标准库提供了专门的读写csv的包encoding/csv。下面的例子演示了csv文件的写入和读取:

package main

import (
	"encoding/csv"
	"fmt"
	"os"
	"strconv"
)

type Post struct {
	Id      int
	Content string
	Author  string
}

func main() {
	csv_file, err := os.Create("posts.csv") // 创建 csv 文件
	if err != nil {
		panic(err)
	}
	defer csv_file.Close()

	data_posts := []Post{
		{Id: 1, Content: "你好, Golang", Author: "张三"},
		{Id: 2, Content: "你好, C++", Author: "李四"},
		{Id: 3, Content: "你好, Java", Author: "王五"},
		{Id: 4, Content: "你好, C", Author: "张三"},
	}

	writer := csv.NewWriter(csv_file) // 创建写入器(Writer型对象),参数为目标写入文件
	for _, post := range data_posts {
		line := []string{strconv.Itoa(post.Id), post.Content, post.Author}
		err := writer.Write(line) // 用写入器写入字符串数组(每个元素对应一个字段)
		if err != nil {
			panic(err)
		}
	}
	writer.Flush() // 写入器是带缓冲的,需要刷写确保全部写完

	file, err := os.Open("posts.csv") // 打开 csv 文件
	if err != nil {
		panic(err)
	}
	defer file.Close()

	reader := csv.NewReader(file)   // 创建读取器(Reader型对象),参数为目标读取文件
	reader.FieldsPerRecord = -1     // 正的指定字段数,0按第一个记录确定字段数,负的变长字段数
	record, err := reader.ReadAll() // 一次性读取所有记录(返回二维字符串数组)
	if err != nil {
		panic(err)
	}

	var posts []Post
	for _, item := range record { // 二维数组记录保存到 posts
		id, _ := strconv.ParseInt(item[0], 0, 0) // 注意:strconv.ParseInt返回int64
		post := Post{Id: int(id), Content: item[1], Author: item[2]}
		posts = append(posts, post)
	}
	fmt.Println(posts[1].Id)
	fmt.Println(posts[1].Content)
	fmt.Println(posts[1].Author)
}

上述代码中,对于csv文件的写入,是一行行写入的,对于读取,则是一次性读取到二维数组中,然后解析该数组还原结构体对象的。对于需要读取的数据量非常大的情况,csv.Reader对象是提供了Read()方法来一行行读取的。同时,为了提高性能,csv.Reader对象有一个ReuseRecord字段来控制是否复用返回的slice(默认每次调用都会分配新的内存)。csv.Reader对象还有其他一些字段来控制是否去除前导空格等。

编解码方式读写文件

某种程度上,前述csv例子我们是手动对写入的数据和读取的数据进行编码和解码的,encoding/gob包提供了更通用的编码和解码方式,而且它不限于文本文件,可以用于二进制文件。

下面的例子演示了gob包中编码器和解码器的使用:

package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
	"io/ioutil"
)

type Post struct {
	Id      int
	Content string
	Author  string
}

func store(data interface{}, filename string) {
	buffer := new(bytes.Buffer)       // 用new初始化编码器所需的缓冲
	encoder := gob.NewEncoder(buffer) // 创建编码器
	err := encoder.Encode(data)       // 用编码器编码数据,数据为接口类型
	if err != nil {
		panic(err)
	}
	err = ioutil.WriteFile(filename, buffer.Bytes(), 0600) // 写入缓冲中编码好的数据
	if err != nil {
		panic(err)
	}
}

func load(data interface{}, filename string) {
	raw, err := ioutil.ReadFile(filename) // 一次性读取文件中所有数据
	if err != nil {
		panic(err)
	}
	buffer := bytes.NewBuffer(raw)    // 将数据放入缓冲
	decoder := gob.NewDecoder(buffer) // 创建解码器
	err = decoder.Decode(data)        // 用解码器解码数据,数据为接口类型
	if err != nil {
		panic(err)
	}
}

func main() {
	post := Post{Id: 1, Content: "你好, Golang", Author: "张三"}
	store(post, "datafile")         // 编码存放到文件,数据是“读”
	var post_read Post
	load(&post_read, "datafile")    // 解码存放到结构体,数据是“写”
	fmt.Println(post_read)
}

上面的代码表明:1、创建编码器和解码器,都需要一个buffer,编码器需要new初始化的buffer,解码器需要放入了原始字节切片数据的buffer(使用bytes.NewBuffer(..)函数完成)。2、上面的代码store(post, "datafile")改成store(&post, "datafile")结果不变,而且似乎传递地址更好一点,可以避免结构体拷贝。3、调用编码器的Encode(..)方法或者调用解码器的Decode(..)方法,都需要传入空接口类型(interface{})的数据data。实际调用方传入参数时,对于编码既可以传值,也可以传地址,因为编码时data是“读”状态;对于解码只能传地址,因为解码时data是“写”状态。这个道理和C语言scanf函数传地址,printf传值是一样的——只是golang空接口类型具有动态类型和动态值,从而“读”时既可以是值形式,也可以地址形式,因为是空接口,内部用反射机制来获得运行时类型。

关于结构体、接口和空接口,可以参考 golang学习随便记4-类型:map、结构体_sjg20010414的博客-CSDN博客

golang学习随便记8-接口_sjg20010414的博客-CSDN博客

golang Interface_golang interface{}_jenrain的博客-CSDN博客

用数据库存储数据

我们终于来到用数据库存储数据的了解。书上是使用 PostgreSQL 数据库,我打算把例子改写成使用 MariaDB数据库。

启动 mariadb 10.3数据库 (我是安装在docker中,用 ./start_mariadb.sh  bash即可启动并进入容器内终端),mysql -u root -p 登录,CREATE DATABASE gwp DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; 创建数据库,GRANT ALL ON gwp.* TO 'gwp'@'%' IDENTIFIED BY 'dbpassword';  创建用户并授权。用下面的语句创建表

MariaDB [gwp]> CREATE TABLE post (
    -> id int NOT NULL AUTO_INCREMENT,
    -> content text,
    -> author varchar(255),
    -> PRIMARY KEY (id)
    -> );

添加驱动:GitHub - go-sql-driver/mysql: Go MySQL Driver is a MySQL driver for Go's (golang) database/sql package

sjg@sjg-PC:~/go/src/db_store1$ go get -u github.com/go-sql-driver/mysql
go: downloading github.com/go-sql-driver/mysql v1.7.1
go get: added github.com/go-sql-driver/mysql v1.7.1

改写书上这部分代码有一点点障碍,一个是书上使用$1、$2、$3等占位符报错,无论是查阅别人帖子还是golang官网例子代码(sql package - database/sql - Go Packages),占位符都是?。另一个是 mariadb 10.3 版本不够高,因此和mysql一样(不清楚高版本mysql情况)不支持插入时 RETURNING id值,我们需要额外工作来获取id (还好 mariadb/mysql  有 LAST_INSERT_ID() 函数):

package main

import (
	"database/sql"
	"fmt"
	"log"

	_ "github.com/go-sql-driver/mysql"
)

type Post struct {
	Id      int
	Content string
	Author  string
}

var Db *sql.DB

func init() { // 此 init 不显式调用,自动隐式调用,实现初始化全局变量 Db
	var err error
	Db, err = sql.Open("mysql", "gwp:dbpassword@tcp(172.17.0.1:3306)/gwp?charset=utf8mb4,utf8")
	if err != nil {
		panic(err)
	}
}

func Posts(limit int) (posts []Post, err error) {
	rows, err := Db.Query("SELECT id, content, author FROM post LIMIT ?", limit) // Query(..) 预期返回多行结果集
	if err != nil {
		return
	}
	for rows.Next() { // 用循环遍历多行结果集
		post := Post{}
		err = rows.Scan(&post.Id, &post.Content, &post.Author) // Scan(..) 将结果集当前列值绑定到变量
		if err != nil {
			return
		}
		posts = append(posts, post) // 结果依次放入切片 posts
	}
	rows.Close() // 关闭结果集,清理内存
	return
}

func GetPost(id int) (post Post, err error) {
	post = Post{}
	err = Db.QueryRow("SELECT id, content, author FROM post WHERE id = ?", id). // QueryRow(..) 预期返回单行结果集
											Scan(&post.Id, &post.Content, &post.Author) // pgsql 可以 RETURNING id
	return
}

func (post *Post) Create() (err error) {
	sql := "INSERT INTO post (content, author) VALUES (?, ?)"
	stmt, err := Db.Prepare(sql) // 对于插入使用准备语句
	if err != nil {
		log.Fatal(err)
		return
	}
	defer stmt.Close()

	// err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)  // pgsql 可以一步设置 post.id
	if result, err := stmt.Exec(post.Content, post.Author); err != nil { // Exec(..)返回执行结果
		log.Fatal(err)
	} else {
		if last_insert_id, err := result.LastInsertId(); err != nil {
			log.Fatal(err)
		} else {
			post.Id = int(last_insert_id) // Mariadb/MySQL应该支持执行结果的 LastInsertId()
		}
	}
	return
}

func (post *Post) Update() (err error) {
	_, err = Db.Exec("UPDATE post SET content = ?, author = ? WHERE id = ?",
		post.Content, post.Author, post.Id)
	return
}

func (post *Post) Delete() (err error) {
	_, err = Db.Exec("DELETE FROM post WHERE id = ?", post.Id)
	return
}

func main() {
	post := Post{Content: "你好, C++", Author: "李四"}

	fmt.Println(post) // 插入记录前
	post.Create()
	fmt.Println(post) // 插入记录后

	post_read, _ := GetPost(post.Id)
	fmt.Println(post_read) // 获取刚刚插入的记录

	post_read.Content = "你好, Java"
	post_read.Author = "赵六"
	post_read.Update()

	posts, _ := Posts(5)
	fmt.Println(posts) // 获取所有记录

	post_read.Delete() // 删除记录
}

输出结果:

sjg@sjg-PC:~/go/src/db_store1$ go run .
{0 你好, C++ 李四}
{1 你好, C++ 李四}
{1 你好, C++ 李四}
[{1 你好, Java 赵六}]
sjg@sjg-PC:~/go/src/db_store1$ go run .
{0 你好, C++ 李四}
{2 你好, C++ 李四}
{2 你好, C++ 李四}
[{2 你好, Java 赵六}]

值得注意的是,Db变量的类型 *sql.DB,其实不是数据库连接的意义,它是一个数据库句柄,它代表包含0个或者多个数据库连接的连接池,因此,有些代码会命名为pool。

代码的头部使用了匿名导入,另外,代码中使用了包的init()函数用来初始化Db变量,可以参考  golang学习随便记14-包和工具_sjg20010414的博客-CSDN博客golang中的init初始化函数_golang init函数_六月的的博客-CSDN博客

Golang中有context的概念(context包),database/sql包支持context,可以实现超时控制、性能日志等功能,具体表现是很多函数有2个版本,例如DB类型有Prepare(query)方法和PrepareContext(ctx, query)方法。关于context可以参考 详解golang中的context - 知乎

要执行事务,并不复杂,大致步骤是:调用 Db.Begin() 返回事务对象tx,Tx类型具有和DB相似的一些方法,因此,原来用 Db 的地方换成tx,然后就是提交事务。我们把 Create() 改成事务方式,大致如下:

func (post *Post) Create() (err error) {
	sql := "INSERT INTO post (content, author) VALUES (?, ?)"
	tx, err := Db.Begin() // 启动事务 tx
	if err != nil {
		log.Fatal(err)
		return
	}
	defer tx.Rollback() // 事务被提交后此句无效

	// stmt, err := Db.Prepare(sql)
	stmt, err := tx.Prepare(sql) // Tx 类型 有和 DB 类型相似的一些方法
	if err != nil {
		log.Fatal(err)
		return
	}
	defer stmt.Close()

	// err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)  // pgsql 可以一步设置 post.id
	if result, err := stmt.Exec(post.Content, post.Author); err != nil {
		log.Fatal(err)
	} else {
		if last_insert_id, err := result.LastInsertId(); err != nil {
			log.Fatal(err)
		} else {
			post.Id = int(last_insert_id) // Mariadb/MySQL应该支持执行结果的 LastInsertId()
		}
	}

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

我们来看看带关联表时如何操作数据库。用下面的语句创建关联表 comment

MariaDB [gwp]> CREATE TABLE comment (
    -> id int NOT NULL AUTO_INCREMENT,
    -> content text,
    -> author varchar(255),
    -> post_id int,
    -> PRIMARY KEY (id),
    -> FOREIGN KEY (post_id) REFERENCES post(id)
    -> );

新建一个项目 db_store2,编写如下代码(大量代码和前述相同,就省略了):

package main

import (
	"database/sql"
	"errors"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"log"
	"time"
)

type Post struct {
	Id       int
	Content  string
	Author   string
	Comments []Comment
}

type Comment struct {
	Id      int
	Content string
	Author  string
	Post    *Post
}

var Db *sql.DB

func init() { // 此 init 不显式调用,自动隐式调用,实现初始化全局变量 Db
// ..................
}

func (comment *Comment) Create() (err error) {
	if comment.Post == nil {
		err = errors.New("帖子未找到")
		return
	}
	var result sql.Result
	result, err = Db.Exec(`INSERT INTO comment (content, author, post_id) 
		VALUES (?, ?, ?)`, comment.Content, comment.Author, comment.Post.Id)
	if err != nil {
		return
	}
	var last_insert_id int64
	last_insert_id, err = result.LastInsertId()
	if err != nil {
		return
	}
	comment.Id = int(last_insert_id)
	return
}

func Posts(limit int) (posts []Post, err error) {
// .................................
}

func GetPost(id int) (post Post, err error) {
	post = Post{}
	post.Comments = []Comment{}
	err = Db.QueryRow("SELECT id, content, author FROM post WHERE id = ?", id). // QueryRow(..) 预期返回单行结果集
											Scan(&post.Id, &post.Content, &post.Author) // pgsql 可以 RETURNING id
	rows, err := Db.Query("SELECT id, content, author FROM comment")
	if err != nil {
		return
	}
	for rows.Next() {
		comment := Comment{Post: &post}
		err = rows.Scan(&comment.Id, &comment.Content, &comment.Author)
		if err != nil {
			return
		}
		post.Comments = append(post.Comments, comment)
	}
	rows.Close()
	return
}

func (post *Post) Create() (err error) {
// ......................................
}

func (post *Post) Update() (err error) {
// ......................................
}

func (post *Post) Delete() (err error) {
// ......................................
}

func main() {
	post := Post{Content: "你好, C++! " + time.Now().Format("15:04:05"), Author: "李四"}
	post.Create()

	comment := Comment{Content: "C++确实好,就是太难学" + time.Now().Format("15:04:05"), Author: "张三", Post: &post}
	comment.Create()

	post_read, _ := GetPost(post.Id)

	fmt.Println(post_read)                  // 获取帖子
	fmt.Println(post_read.Comments)         // 获取帖子的评论
	fmt.Println(post_read.Comments[0].Post) // 验证帖子第一条评论对应的帖子是否为自身
}

显示结果如下:

sjg@sjg-PC:~/go/src/db_store2$ go run .
{3 你好, C++! 15:54:29 李四 [{1 C++确实好,就是太难学15:54:29 张三 0xc000032140}]}
[{1 C++确实好,就是太难学15:54:29 张三 0xc000032140}]
&{3 你好, C++! 15:54:29 李四 [{1 C++确实好,就是太难学15:54:29 张三 0xc000032140}]}

我们从代码发现,要构建一对多关系,就在代表“一”的结构体里,添加代表“多”的切片(切片本质上是指针);反过来,在代表“多”的结构体里,也添加一个指向“多”的指针成员。可以认为,post有一个指针指向comments列表,列表成员有一个指针指向post,这么设计和yii2中的Model对关系的处理是类似的。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值