golang web学习随便记4

这篇具有很好参考价值的文章主要介绍了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类似,既可以一次性直接将数据写入文件或者从文件读取数据,也可以先创建或者打开文件,再读写数据

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对关系的处理是类似的。文章来源地址https://www.toymoban.com/news/detail-438874.html

到了这里,关于golang web学习随便记4的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 深入了解Web3:区块链技术如何改变我们的数字世界

    在过去的几年中,Web3和区块链技术逐渐成为了技术界和社会大众关注的焦点。从初始的加密货币到现在的去中心化应用(DApps)和智能合约,区块链技术已经开始改变我们的数字世界的面貌。在本文中,我们将深入探讨Web3和区块链技术,以及它们如何改变我们的生活和工作方

    2024年04月22日
    浏览(72)
  • 物联网行业的革命:Web3 技术如何改变我们的日常生活

    物联网 (IoT) 是一个充满创新和潜力的领域,它将物理设备、传感器和互联网连接起来,实现智能化和自动化。 在过去几年中,从智能家居、智能城市到工业自动化,物联网技术已经渗透到了各个领域。然而,随着物联网设备和系统的数量不断增加,如何确保这些设备和系统

    2024年02月13日
    浏览(55)
  • Golang个人web框架开发-学习流程

    github地址:ameamezhou/golang-web-frame 后续还将继续学习更新 设置免密登录 ssh-keygen 一路回车就OK 上面有告诉你密钥生成地址 红框为需要上传的公钥 首先明确目标– 我们学习开发web框架的目的是 : 在日常的web开发中,我们经常要使用到web框架, python 就有很多好用的框架,比如

    2024年01月19日
    浏览(38)
  • PostgreSQL是什么?它有什么功能和特性?它值不值得我们去学习?我们该如何去学习呢?

    PostgreSQL是一种开源的对象关系数据库管理系统(ORDBMS),它是一种高度可靠的数据库系统,具有丰富的功能和强大的性能。PostgreSQL的发展历史可以追溯到1986年,最初是由加拿大的计算机科学家Michael Stonebraker领导的一支研究小组开发的。PostgreSQL是一个强大的数据库系统,它

    2024年01月20日
    浏览(83)
  • 【Web】从零开始的js逆向学习笔记(上)

    目录 一、逆向基础 1.1 语法基础 1.2 作用域 1.3 窗口对象属性 1.4 事件 二、浏览器控制台 2.1 Network Network-Headers Network-Header-General Network-Header-Response Headers Network-Header-Request Headers 2.2 Sources 2.3 Application 2.4 Console 三、加密参数的定位方法 3.1 巧用搜索 3.2 堆栈调试 3.3 控制台调试 3.

    2024年02月21日
    浏览(45)
  • C#COM是什么?它有什么功能和特性?它值不值得我们去学习?我们该如何去学习呢?

    C#COM是C# Component Object Model的缩写,是一种用于创建可重用组件的技术。C#COM允许开发人员使用C#编程语言创建可在不同应用程序和系统中重复使用的组件。这些组件可以包括类、接口、方法和属性等,可以被其他应用程序或系统调用和使用。 C#COM技术基于COM(Component Object Mod

    2024年01月20日
    浏览(88)
  • 【手写数据库】从零开始手写数据库内核,行列混合存储模型,学习大纲成型了

    ​ 专栏内容 : 参天引擎内核架构 本专栏一起来聊聊参天引擎内核架构,以及如何实现多机的数据库节点的多读多写,与传统主备,MPP的区别,技术难点的分析,数据元数据同步,多主节点的情况下对故障容灾的支持。 手写数据库toadb 本专栏主要介绍如何从零开发,开发的

    2024年02月04日
    浏览(60)
  • 【从零开始的rust web开发之路 一】axum学习使用

    第一章 axum学习使用 本职java开发,兼架构设计。空闲时间学习了rust,目前还不熟练掌握。想着用urst开发个web服务,正好熟悉一下rust语言开发。 目前rust 语言web开发相关的框架已经有很多,但还是和java,go语言比不了。 这个系列想完整走一遍web开发,后续有时间就出orm,还

    2024年02月12日
    浏览(54)
  • REST2SQL是什么?它有什么功能和特性?它值不值得我们去学习?我们该如何去学习呢?

    REST2SQL是一种将RESTful API转换为SQL查询的工具或技术。它可以将RESTful API中的请求转换为对数据库的SQL查询,以便从数据库中检索、更新或删除数据。 REST2SQL的工作原理是通过分析RESTful API的请求参数和路径,将其转换为相应的SQL查询语句。这样可以实现将RESTful API的请求直接映

    2024年01月18日
    浏览(68)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包