Go语言编程笔记16:存储数据

2021/12/30 17:07:40

本文主要是介绍Go语言编程笔记16:存储数据,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

Go语言编程笔记16:存储数据

image-20211108153040805

图源:wallpapercave.com

几乎任何程序都绕不开读写数据,只不过具体的数据存储介质和方式有所不同。本篇文章将从多种数据存储方式进行探讨各种存储方式如何实现以及优劣。

内存

最简单也是最容易想到的方式大概就是用内存存储数据,事实上这也是大多数初学者不经意间最先学到的。

乍一听用内存存储数据显得很高级,其实所有程序运行时申请的变量都是在使用内存来存储数据。

不过作为Web应用,使用内存存储数据必须考虑并发,所以应当使用通道或者互斥锁来实现并发的数据读写。这里给出一个简陋的论坛应用。这个非常简陋的应用只实现了几个核心功能:

  • /login展现一个登陆页面,输入任意的非空用户名和密码可以登录。
  • /all_articles展示目前所有的帖子,包含帖子序号、内容、用户名。并且可以在最下方的文本框输入信息并添加一个新的帖子。可以通过上方的exit链接注销用户并退出。

下面给出完整程序:

main.go

package main

import (
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"sync"
	"time"
)

type Article struct {
	Id      int
	Content string
	Author  string
}

var aMutex sync.RWMutex
var articles []*Article

const COOKIE_USER_NAME = "user_name"

func addArticle(content string, author string) {
	aMutex.Lock()
	defer aMutex.Unlock()
	aLen := len(articles)
	newArticle := Article{
		Id:      aLen,
		Content: content,
		Author:  author,
	}
	articles = append(articles, &newArticle)
}

func getArticles() []Article {
	aMutex.RLock()
	defer aMutex.RUnlock()
	var aCopy []Article
	for _, a := range articles {
		aCopy = append(aCopy, *a)
	}
	return aCopy
}

func getUserName(rw http.ResponseWriter, r *http.Request) (userName string, err error) {
	lc, err := r.Cookie(COOKIE_USER_NAME)
	if err != nil || lc.Value == "" {
		//去登录
		rw.Header().Set("Location", "/login")
		rw.WriteHeader(301)
		err = errors.New("need login")
		return
	}
	userName = lc.Value
	return
}

func doLogin(rw http.ResponseWriter, user string) {
	lc := http.Cookie{
		Name:  COOKIE_USER_NAME,
		Value: user,
	}
	rw.Header().Set("Set-Cookie", lc.String())
}

func unLogin(rw http.ResponseWriter) {
	lc := http.Cookie{
		Name:    COOKIE_USER_NAME,
		Value:   "",
		Expires: time.Unix(1, 0),
		MaxAge:  -1,
	}
	rw.Header().Set("Set-Cookie", lc.String())
}

func login(rw http.ResponseWriter, r *http.Request) {
	t := template.Must(template.ParseFiles("login.html"))
	t.Execute(rw, nil)
}

func loginCheck(rw http.ResponseWriter, r *http.Request) {
	un := r.PostFormValue("user_name")
	p := r.PostFormValue("password")
	if un == "" && p == "" {
		log.Fatal(errors.New("user name and password is empty"))
	}
	doLogin(rw, un)
	rw.Header().Set("Location", "/all_articles")
	rw.WriteHeader(301)
}

func allArticles(rw http.ResponseWriter, r *http.Request) {
	data := struct {
		UserName string
		Articles []Article
	}{}
	data.Articles = getArticles()
	un, err := getUserName(rw, r)
	if err != nil {
		fmt.Println(err)
		rw.Header().Set("Location", "/login")
		rw.WriteHeader(301)
		return
	}
	data.UserName = un
	t := template.Must(template.ParseFiles("articles.html"))
	t.Execute(rw, data)
}

func addArticleHandle(rw http.ResponseWriter, r *http.Request) {
	content := r.PostFormValue("content")
	author, err := getUserName(rw, r)
	if err != nil {
		fmt.Println(err)
		return
	}
	addArticle(content, author)
	rw.Header().Set("Location", "/all_articles")
	rw.WriteHeader(301)
}

func exitHandle(rw http.ResponseWriter, r *http.Request) {
	unLogin(rw)
	rw.Header().Set("Location", "/login")
	rw.WriteHeader(301)
}

func main() {
	http.HandleFunc("/login", login)
	http.HandleFunc("/login_check", loginCheck)
	http.HandleFunc("/all_articles", allArticles)
	http.HandleFunc("/add_article", addArticleHandle)
	http.HandleFunc("/exit", exitHandle)
	http.ListenAndServe(":8080", nil)
}

login.html

<!DOCTYPE html>
<html>
    <body>
        <form action="/login_check" method="post">
            user name:<input name="user_name" type="text"/><br/>
            password:<input name="password" type="password"/><br/>
            <input type="submit" value="login"/>
        </form>
    </body>
</html>

articles.html

<html>
    <body>
        <div>welcome {{ .UserName }}!<a href="/exit">exit</a></div>
        <table>
            <tr>
                <th>id</th>
                <th>content</th>
                <th>author</th>
                <th>option</th>
            </tr>
            {{ range .Articles }}
            <tr>
                <td>{{ .Id }}</td>
                <td>{{ .Content }}</td>
                <td>{{ .Author }}</td>
                <td></td>
            </tr>
            {{ else }}
            <tr>
                <td colspan="4">no articles to show.</td>
            </tr>
            {{ end }}
        </table>
        <form action="/add_article" method="post">
            add new article:<br/>
            <textarea name="content"></textarea><br/>
            <input type="submit" value="add"/><br/>
        </form>
    </body>
</html>

这个应用非常简陋,没有真正实现用户验证,且用Cookie来作为登录凭证,这显然是不合适的,但这里仅作为一个演示程序,用于说明如何进行数据存储。

这里的核心数据是包变量articles,为了能在多个服务goroutine中分享这个数据,这里使用了互斥锁进行保护。为了方便读写共享数据,创建了addArticlegetArticles这两个辅助函数,因为使用了互斥锁,所以是线程安全的,处理器可以通过这两个函数读写文章列表。

这个示例程序运行后,可以登录并发帖,也可以注销后以另一个用户登录,此时已然可以看到所有帖子。但这个程序有一个问题,如果中止程序后再启动,就会发现之前录入的帖子全部消失了。

这是理所当然的,因为articles仅存在于内存中,程序一旦退出,内存中相关变量都会销毁,自然数据也就丢失了。如果要让重新启动的程序依然保留一些之前产生的数据,就要考虑数据持久化

所谓的数据持久化就是让数据长效保存,相比内存,硬盘自然算是长效保存介质。所以让数据以文件形式保存在硬盘上就是一种数据持久化方式。

文件

当然也不能无目的地随意将数据写入文件,因为那样不利于数据提取。

在编程领域中,将数据持久化在某种意义上来说是和“转化为字符串”可以等同,不同的编程语言支持不同的持久化方案,比如PHP常用的有序列化函数serialize和反序列化函数unserialize

但几乎所有的编程语言都支持将数据转化为JSON或者XML,因为这两者是一种公开的标准格式。

这里以JSON为例。

JSON

考虑到各种因素,这里我选择在用户输入的帖子是5的倍数后将帖子转化为JSON字符串存储到文件,并且在程序启动时检测是否有JSON文件,如果有就将其中的数据加载到包变量articles

...
type Article struct {
	Id      int    `json:"id"`
	Content string `json:"content"`
	Author  string `json:"author"`
}
...
func addArticle(content string, author string) {
	aMutex.Lock()
	defer aMutex.Unlock()
	aLen := len(articles)
	newArticle := Article{
		Id:      aLen,
		Content: content,
		Author:  author,
	}
	articles = append(articles, &newArticle)
	//如果当前文章条目是5的整数倍,进行备份
	lenA := len(articles)
	if lenA%5 == 0 && lenA != 0 {
		go backupArticles()
	}
}
...
const DATA_FILE = "data.json"

func backupArticles() {
	as := getArticles()
	bytes, err := json.Marshal(as)
	if err != nil {
		panic(err)
	}
	err = ioutil.WriteFile(DATA_FILE, bytes, 0644)
	if err != nil {
		panic(err)
	}
}

func getArticlesByBackup() []Article {
	as := make([]Article, 0)
	bytes, err := ioutil.ReadFile(DATA_FILE)
	if err != nil {
		//文件不存在,返回空切片
		return as
	}
	err = json.Unmarshal(bytes, &as)
	if err != nil {
		panic(err)
	}
	return as
}

func main() {
	//如果存在备份数据,加载
	as := getArticlesByBackup()
	if len(as) > 0 {
		for _, a := range as {
			a := a
			articles = append(articles, &a)
		}
	}
	http.HandleFunc("/login", login)
	http.HandleFunc("/login_check", loginCheck)
	http.HandleFunc("/all_articles", allArticles)
	http.HandleFunc("/add_article", addArticleHandle)
	http.HandleFunc("/exit", exitHandle)
	http.ListenAndServe(":8080", nil)
}

需要注意的是,为了不影响正常的用户请求,在处理器中调用backupArticles备份数据到文件时,应当另开一个goroutine。除此之外没有什么太值得所的,无非是使用json包进行JSON和反JSON,以及使用ioutil包读写文件。

csv

JSON这种格式开发者会很熟悉,但普通人可能摸不着头脑,让普通人查看JSON字符串那绝对是一种折磨。

对普通人来说CSV这种格式可能更友好,事实上我原来参与过财务和OA系统开发,大量工作都是做一个网页,让用户来上传CSV文件以批量录入信息到系统。

通常CSV会被错误地认为是EXCEL的一种简易格式,但其实这是一种公开的电子表格标准,有多种电子表格软件都支持它。

简单的说,CSV格式很像是一个没有字段的数据库表,很简陋的那种,其中的字段以,分隔:

0,,222
1,111,222
2,22324,222
3,111,222
4,1111,222

简陋意味着可读性良好,你可以轻松地在记事本中自己编写一个CSV文件。

虽然说一般情况下CSV是用于批量录入信息的,程序持久化不太会使用CSV文件,但我们依然可以尝试使用CSV作为我们简陋论坛的持久化方式,至少这期间可以学习如何使用Go来读写CSV文件。

...
const DATA_FILE = "data.csv"

//备份文章数据
func backupArticles() {
	as := getArticles()
	//如果存在备份文件,打开,不存在,创建。
	f, err := os.OpenFile(DATA_FILE, os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0666)
	if err != nil {
		panic(err)
	}
	defer f.Close()
	r := csv.NewWriter(f)
	for _, a := range as {
		record := []string{strconv.Itoa(a.Id), a.Content, a.Author}
		r.Write(record)
	}
	r.Flush()
}

func getArticlesByBackup() []Article {
	as := make([]Article, 0)
	f, err := os.Open(DATA_FILE)
	if err != nil {
		//文件不存在,返回空切片
		return as
	}
	defer f.Close()
	reader := csv.NewReader(f)
	reader.FieldsPerRecord = 0
	records, err := reader.ReadAll()
	if err != nil {
		panic(err)
	}
	for _, rec := range records {
		id, err := strconv.Atoi(rec[0])
		if err != nil {
			panic(err)
		}
		newA := Article{
			Id:      id,
			Content: rec[1],
			Author:  rec[2],
		}
		as = append(as, newA)
	}
	return as
}
...

这里使用了encoding/csv这个包来读写CSV文件,值得一提的是,读取时需要利用csv.Reader,并且需要设定Reader.FieldsPerRecord这个字段。这个字段可以设置三种值:

  • -1,读取数据时不考虑每行数据的字段个数,即使越界也不会中断程序。
  • 0,每行数据的字段个数视为第一行数据的字段个数。
  • 1~n,手动指定每行数据的字段个数。

CSV格式的缺点是必须严格按照顺序读写,否则就会出错,扩展性要差一些。

gob

每种编程语言都有自己的序列化方式,Go可以通过encoding/gob包实现序列化。

...
const DATA_FILE = "data.gob"

func backupArticles() {
	as := getArticles()
	buf := new(bytes.Buffer)
	encoder := gob.NewEncoder(buf)
	err := encoder.Encode(as)
	if err != nil {
		panic(err)
	}
	bs := buf.Bytes()
	err = ioutil.WriteFile(DATA_FILE, bs, 0644)
	if err != nil {
		panic(err)
	}
}

func getArticlesByBackup() []Article {
	as := make([]Article, 0)
	bs, err := ioutil.ReadFile(DATA_FILE)
	if err != nil {
		//文件不存在,返回空切片
		return as
	}
	buf := bytes.NewBuffer(bs)
	decoder := gob.NewDecoder(buf)
	err = decoder.Decode(&as)
	if err != nil {
		panic(err)
	}
	return as
}
...

使用gobjson的方式很类似,一个是将内置类型转化为JSON字符串,一个是转化为二进制字节序列。

需要注意的是,使用gob的过程中需要借助bytes.Buffer这个表示字节缓冲的结构。

最终生成的持久化文件data.gob是一个二进制文件,是没法使用文本编辑器查看的,但程序依然可以正常反序列化后加载数据。

数据库

虽然对于一些简单的应用,的确可以使用文件来读写数据(事实上我做的一个Markdown工具就是这么做的),但这样做一来会限制数据结构的可扩展性,二来文件本身的并发读写性能是比较差的,会影响到应用的性能。

所以正式的应用都会使用数据库作为存储系统。

常见的关系型数据库有Oracle\MySQL等,今天要使用的是PostgreySQL,这是一个和MySQL类似的免费开源数据库,go对其的支持较好。

在介绍实际的用Go操作数据库之前,需要先安装并了解一些PSQL的基本操作,这里推荐阅读我的另一篇博客PSQL 简易教程 - 魔芋红茶’s blog (icexmoon.xyz)。

现在为这个简易论坛创建数据库和用户,我创建的数据库名是bbs,数据库用户名bbs,密码bbs_admin

还需要创建相应的表结构,这里需要创建三张表:

  • user,用户表
  • article,帖子
  • comment,回复

具体的SQL语句:

CREATE TABLE IF NOT EXISTS public.article
(
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
    content character varying COLLATE pg_catalog."default" NOT NULL,
    user_id bigint NOT NULL,
    ctime time without time zone NOT NULL DEFAULT now(),
    CONSTRAINT articles_pkey PRIMARY KEY (id)
)

TABLESPACE pg_default;

ALTER TABLE IF EXISTS public.article
    OWNER to bbs;
CREATE TABLE IF NOT EXISTS public.comment
(
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
    content character varying COLLATE pg_catalog."default" NOT NULL,
    user_id bigint NOT NULL,
    ctime time without time zone NOT NULL DEFAULT now(),
    art_id bigint NOT NULL,
    CONSTRAINT comments_pkey PRIMARY KEY (id)
)

TABLESPACE pg_default;

ALTER TABLE IF EXISTS public.comment
    OWNER to bbs;
CREATE TABLE IF NOT EXISTS public."user"
(
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ),
    name character varying COLLATE pg_catalog."default" NOT NULL,
    ctime time without time zone NOT NULL DEFAULT now(),
    password character varying COLLATE pg_catalog."default" NOT NULL,
    CONSTRAINT users_pkey PRIMARY KEY (id)
)

TABLESPACE pg_default;

ALTER TABLE IF EXISTS public."user"
    OWNER to bbs;

sql

Go官方提供的用于数据库连接的包为database/sql,使用它创建一个数据库连接池的方式为:

package model

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
)

var Db *sql.DB

func init() {
	var err error
	connStr := `user=bbs dbname=bbs password=bbs_admin port=5433 host=localhost sslmode=disable`
	Db, err = sql.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
}

为了结构清晰,我对之前的代码进行了重构,将Model层独立出来,数据库连接也写在Model层中。

sql.Open可以创建一个数据库连接池的引用*sql.DB,利用它我们可以操作数据库。需要注意的是,Open函数本身并不会直接连接数据库,真正的连接动作会推迟到执行SQL语句。这和某些编程语言的方式是不同的,这里仅仅是创建了一个对数据库连接池的引用,所以即使给Open函数传入的“数据库连接字符串”是错误的,这里也并不会报错。

此外,还需要给Open函数传入一个数据库驱动名称,对于PSQL,是postgres。真正的驱动相关数据创建是通过引入相关的包来实现的:_ "github.com/lib/pq",因为这个包仅仅用于生成PSQL驱动模型,不会真的在代码中使用,所以采用匿名导入的方式。

Model层主要的作用就是实现相应的CRUD(create\retrieve\update\delete)功能,实际上就是所谓的“增删改查”。

这里以帖子的Model结构Article为例进行说明:

package model

import (
	"time"
)

type Article struct {
	Id      int
	Content string
	UserId  int
	Ctime   time.Time
}

func (a *Article) Add() (err error) {
	stmt, err := Db.Prepare(`insert into 
	article (content, user_id) 
	values ($1,$2) returning id`)
	if err != nil {
		return
	}
	defer stmt.Close()
	stmt.QueryRow(a.Content, a.UserId).Scan(&a.Id)
	return
}

func (a *Article) Delete() (err error) {
	_, err = Db.Exec(`delete from article
	where id=$1`, a.Id)
	return
}

func (a *Article) Get() (err error) {
	Db.QueryRow(`select content,user_id,ctime
	from article
	where id=$1`, a.Id).Scan(&a.Content, &a.UserId, &a.Ctime)
	return
}

func (a *Article) Update() (err error) {
	_, err = Db.Exec(`UPDATE article 
	SET content=$2,user_id=$3
	WHERE id=$1`, a.Id, a.Content, a.UserId)
	return
}

func GetAllArticles() (arts []Article, err error) {
	rows, err := Db.Query("select id,content,user_id,ctime from article")
	if err != nil {
		return
	}
	defer rows.Close()
	for rows.Next() {
		art := Article{}
		rows.Scan(&art.Id, &art.Content, &art.UserId, &art.Ctime)
		arts = append(arts, art)
	}
	return
}

可以看到sql.DB主要支持四种SQL操作:

  • Exec,这个最简单,直接执行SQL。
  • Query,执行SQL查询,并返回一个sql.Rows结构,该结构是一个迭代器,可以使用.Next方法进行迭代,并使用.Scan方法按查询结果的字段位置提取数据。
  • QueryRow,和Query类似,不同的是只会返回第一条结果,而不是结果集,所以可以直接调用.Row方法提取数据。
  • Prepare,可以看做是创建一个可以重复使用的SQL,在真正需要执行SQL时再通过.QueryRow.QueryRows传入参数并执行。在上边的例子中虽然使用了Prepare,但仅调用了一次,并不是很合适,仅作为如何调用的展示。

因为重构后的代码较多,这里不一一进行说明和展示,完整代码见go-notebook/ch16/sql at main · icexmoon/go-notebook (github.com)。

sqlx

可能有人会觉得内建的sql包使用起来太过麻烦,尤其是Query查询后按位置一对一提取数据,事实上有一些第三方包可以简化操作,比如sqlx

sqlx最棒的地方在于其结构和sql包完全兼容,所以将代码从sql切换到sqlx只需要修改极少代码:

package model

import (
	"log"

	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

var Db *sqlx.DB

func init() {
	var err error
	connStr := `user=bbs dbname=bbs password=bbs_admin port=5433 host=localhost sslmode=disable`
	Db, err = sqlx.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
}

上边的示例中只修改了两处代码就完成了切换,其它代码不修改整个应用都可以正常运行。

因为包结构的关系,main.go中对model的引用需要修改,改为"github.com/icexmoon/go-notebook/ch16/sqlx/model"

当然,虽然不修改原有的查询语句程序也可以正常运行,但是为了说明sqlx的优点,这里还是对相关代码进行了重构。

这里以article.go进行说明:

...
type Article struct {
	Id      int       `db:"id"`
	Content string    `db:"content"`
	UserId  int       `db:"user_id"`
	Ctime   time.Time `db:"ctime"`
}
...
func (a *Article) Get() (err error) {
	Db.QueryRowx(`select content,user_id,ctime
	from article
	where id=$1`, a.Id).StructScan(a)
	fmt.Println(*a)
	return
}
...

使用sqlx需要让Model层的结构体建立对数据库表结构的映射,这是以结构体属性标签的方式实现的(类似于JSON标签),例如,上边的db:"id"标签就能让sqlx明白Id属性对应数据库中id这个字段。

创建好映射关系后,使用.QueryRowx方法,并在返回结果上调用.StructScan方法,就无需传入多个属性去提取相应字段,可以直接传入Model结构体的指针:.StructScan(a)sqlx会“自动”将相应查询到的字段赋值给对应的结构体属性。

事实上sqlx可以自动完成一些简单的字段映射(比如Id映射到id)而无需人为指定,但对于复杂映射(比如UserId映射到user_id)就无能为力。所以上边的映射关系可以简化为:

...
type Article struct {
	Id      int
	Content string
	UserId  int `db:"user_id"`
	Ctime   time.Time
}
...

最后要提醒的是,任意的“映射关系”出现问题,都会导致相关的查询结果失败。

sqlx只是在sql包基础上方便了一点,本质上依然是直接编写SQL,事实上有一种ORM技术(object-relational mapper)可以创建一个Model层到数据库的完整映射。使用ORM可以用结构化的方式来执行增删改查,而无需编写完整的SQL。

gorm

Go有多个第三方包支持ORM,其中gorm(go orm)是一个比较流行的包。

引入gorm的方式与sql类似:

package model

import (
	"log"

	"github.com/jinzhu/gorm"
	_ "github.com/lib/pq"
)

var Db *gorm.DB

func init() {
	var err error
	connStr := `user=bbs dbname=bbs2 password=bbs_admin port=5433 host=localhost sslmode=disable`
	Db, err = gorm.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
	Db.AutoMigrate(&Article{}, &User{}, &Comment{})
}

这里Db.AutoMigrate(&Article{}, &User{}, &Comment{})的作用为,让gorm自动创建ArticleUserComment这三个Model层结构体到数据库的映射。比较厉害的是,这种映射关系是完整的,也就是说修改相应的结构体会影响到数据库。比如给User结构体添加一个新属性,相应的表就会添加一个新字段。甚至如果目标数据库没有相应的表,它也会自动创建。所以这里创建了一个空的数据库bbs2来作为配套的数据库,相应的三张表都由gorm自动创建。

对于gorm下的增删改查,这里依然使用article.go进行说明:

package model

import (
	"time"
)

type Article struct {
	Id      int
	Content string `sql:"not null"`
	UserId  int    `sql:"index"`
	Ctime   time.Time
}

func (a *Article) Add() (err error) {
	Db.Create(a)
	return
}

func (a *Article) Delete() (err error) {
	Db.Delete(a, a.Id)
	return
}

func (a *Article) Get() (err error) {
	Db.First(a, a.Id)
	return
}

func (a *Article) Update() (err error) {
	Db.Model(a).Updates(Article{Content: a.Content, UserId: a.UserId})
	return
}

func GetAllArticles() (arts []Article, err error) {
	Db.Find(&arts)
	return
}

代码无疑精简了相当多,所以使用ORM技术有利于缩减Model层的代码规模,让程序员将工作精力集中在编写业务层代码,但相应的,这也会屏蔽SQL细节,给数据库优化带来一些阻碍。

总的来说,无论是否使用ORM,程序员都应当有扎实的SQL和数据库相关理论基础,近些年来业界大量使用ORM,甚至出现“只会使用ORM,不会编写SQL”的程序员,那可就太舍本逐末了。

使用gorm实现简易bbs的完整代码见go-notebook/ch16/gorm at main · icexmoon/go-notebook (github.com)。

谢谢阅读。

参考资料

  • go:文件操作 - 知乎 (zhihu.com)
  • pq - A pure Go postgres driver for Go’s database/sql package
  • postgreSQL在CMD里怎么连接 - 简书 (jianshu.com)
  • HTTP 状态码 | 菜鸟教程 (runoob.com)
  • GORM Guides | GORM - The fantastic ORM library for Golang, aims to be developer friendly.


这篇关于Go语言编程笔记16:存储数据的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程