从问题中学习到Go的精髓
Golang-Learn-In-Problem 从问题中学习到Go的精髓

前言
参考书目及视频:
Go: The Complete Developer's Guide
注:(Go: The Complete Developer's Guide)欢迎去购买支持此课程,非常的棒!
Start!
下面的问题有些是自己思考问自己的,解答来自视频和思考。
还有部分问题来自极客兔兔,回答是官方FAQ(https://go.dev/doc/faq)
Golang为什么要有一个main的包?
这个问题我在第一次学习Go的时候没有深究(因为使用的是Goland进行编写)
Golang的包分为两种:可执行包和可重复使用的包 or 依赖类型的包(里面放了很多可重用的辅助函数或可重用的东西),后者可以再分为包计算者和包上传者,如下图所示

通过实验(使用go build编译)可以看到,如果我把package换成了其他,例如:xiaomi,没有生成文件
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         2022/5/21     21:17             76 main.go
如果归为main包,就可以看到
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         2022/5/21     21:18        1891328 main.exe
-a----         2022/5/21     21:18             74 main.go
代码示例:
package main
import "fmt"
func main() {
	fmt.Println("Hello, world!")
}
Go中的import是个什么原理?
我的理解是通过import连接其它的可重用文件(链接其他包)

【比较学习,举一反三】Go在声明上和Java有什么区别?
Java声明函数举例:
public void main(String[] args) {}
Go函数声明:
func main() string {}
Java声明变量:
String a = "123";
Go声明变量:
var a string = "123"
or
a := "123"
Java声明数组:
int[] a = {1,2,3,4}
Go的切片:
a := []int{1, 2, 3, 4}
其中的不同就是Go的切片可以相当于Java使用ArrayList
a = append(a, 5)
Java的for循环
for (int i = 0; i < a.length(); i++) {}
or
for (int ac in a) {}
or (if a is a List)
a.stream().forEach(e -> xxx)
Go的for循环
for i, num := range a {}
Python的for循环
for i,num in range(a):
    xxxx
Go的自定义类型:
type num []int
甚至可以为这个自定义类型声明一个成员函数
type num []int
func (n num) print() {
	for i, nu := range n {
		fmt.Println(i, nu)
	}
}

上述中的 func (n num)中的(n num)实际上是接收器参数,每一个num类型的变量都可以调用此函数,老师的意思是实际上这是一个引用!
Go中的返回类型和Java中有什么区别?
Go可以返回多个,这个就和Java不一样,首先在函数的屁股后面加上返回的类型和个数,然后可以选择分片返回,这是Java8做不到的
func deal(d deck, handSize int) (deck, deck) {
	return d[:handSize], d[handSize:]
}
这里的分片又和python有异曲同工之妙,用法都一样
Go是如何读写文件的?
写
首先它返回一个error,原因是这个工具包如果出错会抛出一个异常;另外toString做了把一个数组转换为一坨字符串的效果;最后的0666是权限,这个一般是任何人都可以用
func (d deck) saveToFile(filename string) error {
	return ioutil.WriteFile(filename, []byte(d.toString()), 0666)
}
再看[]byte做了什么

最后看它函数的定义
func WriteFile(filename string, data []byte, perm fs.FileMode) error
解释:WriteFile 将数据写入由文件名命名的文件。  如果文件不存在,WriteFile 使用权限 perm(在 umask 之前)创建它;  否则 WriteFile 在写入之前将其截断,而不更改权限。
// 官方例子
func main() {
	message := []byte("Hello, Gophers!")
	err := ioutil.WriteFile("hello", message, 0644)
	if err != nil {
		log.Fatal(err)
	}
}
读
先看看API函数:
func ReadFile(filename string) ([]byte, error)
ReadFile 读取由 filename 命名的文件并返回内容。  成功的调用返回 err == nil,而不是 err == EOF。  因为 ReadFile 读取整个文件,所以它不会将 Read 中的 EOF 视为要报告的错误。
返回值也有不同

当然这时会在想[]byte如何转换成string?当然就是直接转换即可string([]byte)
bs, err := ioutil.ReadFile(filename)
if err != nil {
    // Option #1 - log the error and return a call to newDeck()
    // Option #2 - log the error and entirely quit the program
    fmt.Println("Error:", err)
    os.Exit(1)
}
s := strings.Split(string(bs), ",")
Go什么时候接收器,什么时候又把这个参数放到括号内?
Go的测试功能要怎么写呢?
在Java中直接引入包然后注解@Test即可
在Go中需要导入testing
参数处还有写上:t *testing.T
func TestNewDeck(t *testing.T) {
	d := newDeck()
	if len(d) != 16 {
		t.Errorf("Expected deck length of 16, but got %v", len(d))
	}
	if d[0] != "Ace of Spades" {
		t.Errorf("Expected first card of Ace of Spades, but got %v", d[0])
	}
	if d[len(d)-1] != "Four of Clubs" {
		t.Errorf("Expected last card of Four of Clubs, but got %v", d[len(d)-1])
	}
}
Go如何测试文件的写入和读取?
流程是这样的:
- 删除任何一个当前项目中含有相同名字的文件,例如:"_decktesting"
 - 创建一个想要测试的类
 - 保存这个文件
 - 加载这个文件
 - 断言这个文件的长度
 - 删除现在文件中的包含这个名字的文件,例如:"_decktesting"
 
func TestSaveToDeckAndNewDeckFromFile(t *testing.T) {
	os.Remove("_decktesting")
	d := newDeck()
	d.saveToFile("_decktesting")
	loadedDeck := newDeckFromFile("_decktesting")
	if len(loadedDeck) != 16 {
		t.Errorf("Expected 16 cards in deck, got %v", len(loadedDeck))
	}
	os.Remove("_decktesting")
}
Go中的结构体
老师在课程当中形容的很形象,这个struct就像python中的字典:
type person struct {
	lastName  string
	firstName string
}
func main() {
	alex := person{firstName: "Alex", lastName: "Anderson"}
}
可以通过这个语句查看key-value:fmt.Printf("%+v", alex)
注:%+v 先输出字段类型,再输出该字段的值
Go结构体中调用接收体会在内存中复制的问题
type contactInfo struct {
	email   string
	zipCode int
}
type person struct {
	lastName  string
	firstName string
	contactInfo
}
func main() {
	jim := person{
		firstName: "Jim",
		lastName:  "Party",
		contactInfo: contactInfo{
			email:   "jim@gmail.com",
			zipCode: 94000,
		},
	}
	jim.updateName("Jimmy")
	jim.print()
}
func (pointerToPerson *person) updateName(name string) {
	(*pointerToPerson).firstName = name
}
func (p *person) print() {
	fmt.Printf("%+v", p)
}
如果直接调用接收体这种函数,在内存当中Go会把原先的复制,然后在新的内存中开辟再进行修改,像是下面这个图

那么,我们如果不进行复制这步操作如何呢?首先要使用&获取到这个结构体所在内存的位置,然后通过Go中的指针去改变当前的结构体内容
...............
    jim := person{
        firstName: "Jim",
        lastName:  "Party",
        contactInfo: contactInfo{
            email:   "jim@gmail.com",
            zipCode: 94000,
        },
    }
    jimPointer := &jim
    jimPointer.updateName("Jimmy")
    jim.print()
}
func (pointerToPerson *person) updateName(name string) {
	(*pointerToPerson).firstName = name
}
而每当看到*的时候就是想看到这个指针的值
总结:每当使用接收者作为参数传给函数的时候数据就会被复制,所以默认情况下都是再副本下完成的
而在数组切片中又有所不同:
import "fmt"
func main() {
	mySlice := []string{"Hi", "There", "How", "Are", "You"}
	updateSlice(mySlice)
	fmt.Println(mySlice)
}
func updateSlice(s []string) {
	s[0] = "Bye"
}
-------------------------------------------------
[Bye There How Are You]

每个数组都会分配这样的属性值(length, cap, ptr to head),然后如果要进行切片操作的话就会进行一层复制,但是这个复制只是对其属性的复制,底层还是操作着同一个数组

Map的形容介绍

Go在创建Map的时候有什么不同?
在Go中一般有3种方法创建map
这两个是没有初始化值的
var colors map[string]string
colors ;= make(map[string]string)
如果有值的话:
colors := map[string]string{
		"red":   "#ff0000",
		"green": "#4bf745",
		"white": "#ffffff",
	}
而在Java中一般创建hashmap都是这样:
Map<String, String> map = new HashMap<>();
or
new HashMap<Integer, String>(){
{
        put(1, "one");
        put(2, "two");
}
or
Map.ofEntries(
        entry(1, "one"),
        entry(2, "two"),
        entry(3, "three"),
)
or
Stream.of(
            new SimpleEntry<>(1, "one"),
            new SimpleEntry<>(2, "two"),
            new SimpleEntry<>(3, "three"),
).collect(Collectors.toMap(SimpleEntry::getKey, SimpleEntry::getValue));
而这个map的删除元素的函数也是有所不同,使用delete进行删除
 = 和 := 的区别?
=是赋值变量,:=是定义变量。
指针的作用
一个指针可以指向任意变量的地址,它所指向的地址在32位或64位机器上分别固定占4或8个字节。指针的作用有:
- 获取变量的值
 
 import fmt
 
 func main(){
  a := 1
  p := &a//取址&
  fmt.Printf("%d\n", *p);//取值*
 }
- 改变变量的值
 
 // 交换函数
 func swap(a, b *int) {
     *a, *b = *b, *a
 }
- 用指针替代值传入函数,比如类的接收器就是这样的。
 
 type A struct{}
 
 func (a *A) fun(){}
Go 允许多个返回值吗?
可以。通常函数除了一般返回值还会返回一个error。
Go 有异常类型吗?
有。Go用error类型代替try...catch语句,这样可以节省资源。同时增加代码可读性:
 _,err := errorDemo()
  if err!=nil{
   fmt.Println(err)
   return
  }
也可以用errors.New()来定义自己的异常。errors.Error()会返回异常的字符串表示。只要实现error接口就可以定义自己的异常,
 type errorString struct {
  s string
 }
 
 func (e *errorString) Error() string {
  return e.s
 }
 
 // 多一个函数当作构造函数
 func New(text string) error {
  return &errorString{text}
 }
什么是协程(Goroutine)
协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。
如何高效地拼接字符串
拼接字符串的方式有:"+", fmt.Sprintf, strings.Builder, bytes.Buffer, strings.Join
- "+"
 
使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
- fmt.Sprintf
 
由于采用了接口参数,必须要用反射获取值,因此有性能损耗。
- strings.Builder:
 
用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把[]byte转换为string,从而避免变量拷贝。
strings.builder的实现原理很简单,结构如下:
 type Builder struct {
     addr *Builder // of receiver, to detect copies by value
     buf  []byte // 1
 }
addr字段主要是做copycheck,buf字段是一个byte类型的切片,这个就是用来存放字符串内容的,提供的writeString()方法就是像切片buf中追加数据:
 func (b *Builder) WriteString(s string) (int, error) {
     b.copyCheck()
     b.buf = append(b.buf, s...)
     return len(s), nil
 }
提供的String方法就是将[]byte转换为string类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:
 func (b *Builder) String() string {
     return *(*string)(unsafe.Pointer(&b.buf))
 }
- bytes.Buffer
 
bytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte。使用方式如下:
bytes.buffer底层也是一个[]byte切片,结构体如下:
type Buffer struct {
    buf      []byte // contents are the bytes buf[off : len(buf)]
    off      int    // read at &buf[off], write at &buf[len(buf)]
    lastRead readOp // last read operation, so that Unread* can work correctly.
}
因为bytes.Buffer可以持续向Buffer尾部写入数据,从Buffer头部读取数据,所以off字段用来记录读取位置,再利用切片的cap特性来知道写入位置,这个不是本次的重点,重点看一下WriteString方法是如何拼接字符串的:
func (b *Buffer) WriteString(s string) (n int, err error) {
    b.lastRead = opInvalid
    m, ok := b.tryGrowByReslice(len(s))
    if !ok {
        m = b.grow(len(s))
    }
    return copy(b.buf[m:], s), nil
}
切片在创建时并不会申请内存块,只有在往里写数据时才会申请,首次申请的大小即为写入数据的大小。如果写入的数据小于64字节,则按64字节申请。采用动态扩展slice的机制,字符串追加采用copy的方式将追加的部分拷贝到尾部,copy是内置的拷贝函数,可以减少内存分配。
但是在将[]byte转换为string类型依旧使用了标准类型,所以会发生内存分配:
func (b *Buffer) String() string {
    if b == nil {
        // Special case, useful in debugging.
        return "<nil>"
    }
    return string(b.buf[b.off:])
}
- strings.join
 
strings.join也是基于strings.builder来实现的,并且可以自定义分隔符,代码如下:
func Join(elems []string, sep string) string {
    switch len(elems) {
    case 0:
        return ""
    case 1:
        return elems[0]
    }
    n := len(sep) * (len(elems) - 1)
    for i := 0; i < len(elems); i++ {
        n += len(elems[i])
    }
    var b Builder
    b.Grow(n)
    b.WriteString(elems[0])
    for _, s := range elems[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}
唯一不同在于在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
func main(){
	a := []string{"a", "b", "c"}
	//方式1:
	ret := a[0] + a[1] + a[2]
	//方式2:
	ret := fmt.Sprintf(a[0],a[1],a[2])
	//方式3:
	var sb strings.Builder
	sb.WriteString(a[0])
	sb.WriteString(a[1])
	sb.WriteString(a[2])
	ret := sb.String()
	//方式4:
	buf := new(bytes.Buffer)
	buf.Write(a[0])
	buf.Write(a[1])
	buf.Write(a[2])
	ret := buf.String()
	//方式5:
	ret := strings.Join(a,"")
}
总结:
strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf
什么是 rune 类型
rune是int32的别名,用来区分字符值和整数值。比如utf-8汉字占3个字节,按照一般方法遍历汉字字符串得到的是乱码,这个时候要将字符串转换为rune:
	sample := "我爱GO"
	runeSamp := []rune(sample)
	runeSamp[0] = '你'
	fmt.Println(string(runeSamp))
如何判断 map 中是否包含某个 key ?
var sample map[int]int
if _, ok := sample[10];ok{
}else{
}
Go 支持默认参数或可选参数吗?
不支持。但是可以利用结构体参数,或者...传入参数切片。
defer 的执行顺序
defer执行顺序和调用顺序相反,类似于栈先进后出。
如何交换 2 个变量的值?
对于变量而言a,b = b,a; 对于指针而言*a,*b = *b, *a
Go 语言 tag 的用处?
tag可以为结构体成员提供属性。常见的:
- json序列化或反序列化时字段的名称
 - db: sqlx模块中对应的数据库字段名
 - form: gin框架中对应的前端的数据字段名
 - binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端
 
如何判断 2 个字符串切片(slice) 是相等的?
利用反射:
type Author struct {
	Name         int      `json:Name`
	Publications []string `json:Publication,omitempty`
}
func main() {
	t := reflect.TypeOf(Author{})
	for i := 0; i < t.NumField(); i++ {
		name := t.Field(i).Name
		s, _ := t.FieldByName(name)
		fmt.Println(s.Tag)
	}
}
 字符串打印时,%v 和 %+v 的区别
%v输出结构体各成员的值;
%+v输出结构体各成员的名称和值;
%#v输出结构体名称和结构体各成员的名称和值
Go 语言中如何表示枚举值(enums)?
在常量中用iota可以表示枚举。iota从0开始。
const (
	B = 1 << (10 * iota)
	KiB 
	MiB
	GiB
	TiB
	PiB
	EiB
)
空 struct{} 的用途
- 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。
 - 有时候给通道发送一个空结构体,channel<-struct{}{},也是节省了空间。
 - 仅有方法的结构体
 
go里面的int和int32是同一个概念吗?
不是一个概念!千万不能混淆。go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节。如果是64位操作系统,int类型的大小就是8个字节。除此之外uint也与操作系统有关。
int8占1个字节,int16占2个字节,int32占4个字节,int64占8个字节。
Java过渡Go的第一个项目:TodoList
这个章节主要是学习Gin的项目架构搭建,如果以后要搭建web项目直接起一个脚手架项目很快就能搭建起来,如果项目架构都不熟悉,那么怎么使用脚手架呢?
前言
感谢以下仓库对我学习的帮助:
- Automatically generate RESTful API documentation with Swagger 2.0 for Go.
 - Gin+Gorm+Redis+Swagger 基于 RESTful API 规范搭建备忘录
 - Gin+Gorm开发Golang API快速开发脚手架
 
注:备忘录的架构是在Singo的基础上搭建的。
搭建外设
首先搭建脚手架,按照Singo的结构先学习Golang在Gin上的基本架构:

把文件搭建起来后可以开始先给main.go,可以看到TodoList仓库中主要分三步走:
func main() { // http://localhost:3000/swagger/index.html
	//从配置文件读入配置
	conf.Init()
	//转载路由 swag init -g common.go
	r := routes.NewRouter()
	_ = r.Run(conf.HttpPort)
}
那么首先把init先写上,说明我们第一步要做的就是初始化配置(本次教程放弃了读取ini,改用yaml文件):
- 创建YAML配置文件(放弃redis,根本用不到)
 
service:
  mode: debug
  port: :8080
#redis:
#  addr: 127.0.0.1:6379
mysql:
  host: 用户名:密码@tcp(地址:端口号)/数据库?charset=utf8&parseTime=True&loc=Local
- 然后再conf文件下创建一个
init.go,读取yaml,然后分别丢给数据库(mysql),Gin(service) 
import (
	"TodoList/model"
	"TodoList/util"
	"gopkg.in/yaml.v3"
	"io/ioutil"
)
type Config struct {
	Service *Service `yaml:"service"`
	DB      *DB      `yaml:"mysql"`
}
type Service struct {
	AppMode  string `yaml:"mode"`
	HttpPort string `yaml:"port"`
	//RedisAddr string `yaml:"redis.addr"`
}
type DB struct {
	DbHost string `yaml:"host"`
}
var Port string
// Init 初始化配置项
func Init() {
	var ginProperties Config
	file, err := ioutil.ReadFile("./conf.yaml")
	if err != nil {
		util.Log().Error("配置文件读取错误,请检查文件路径:", err)
	}
	if err := LoadLocales("conf/locales/zh-cn.yaml"); err != nil {
		util.Log().Error("i18n配置文件读取错误,请检查文件路径:", err) //日志内容
	}
	if err = yaml.Unmarshal(file, &ginProperties); err != nil {
		util.Log().Error("YAML文件解析失败") //日志内容
	}
	model.Database(ginProperties.DB.DbHost)
	Port = ginProperties.Service.HttpPort
}
- 然后把Singo的日志文件给复制过来,放到util文件夹下(忽略jwt,这是后面才会用到):
 
- 还可以复制的有conf下的i18n文件,locales和i18n.go都可以复制到conf文件下:
 
建立数据库
- 在搭建外设的章节可以顺着思路下来,已经把配置文件的sql连接地址转交给了gorm(model/init.go),这里singo也把连接数据库封装好了,直接拿过来用:
 
// DB 数据库链接单例
var DB *gorm.DB
// Database 在中间件中初始化mysql链接
func Database(connString string) {
	// 初始化GORM日志配置
	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
		logger.Config{
			SlowThreshold:             time.Second, // Slow SQL threshold
			LogLevel:                  logger.Info, // Log level(这里记得根据需求改一下)
			IgnoreRecordNotFoundError: true,        // Ignore ErrRecordNotFound error for logger
			Colorful:                  false,       // Disable color
		},
	)
	db, err := gorm.Open(mysql.Open(connString), &gorm.Config{
		Logger: newLogger,
	})
	// Error
	if connString == "" || err != nil {
		util.Log().Error("mysql lost: %v", err)
		panic(err)
	}
	sqlDB, err := db.DB()
	if err != nil {
		util.Log().Error("mysql lost: %v", err)
		panic(err)
	}
	//设置连接池
	//空闲
	sqlDB.SetMaxIdleConns(10)
	//打开
	sqlDB.SetMaxOpenConns(20)
	DB = db
	migration()
}
- 但是此时的运行不起来的,要完成migration,这属于GORM的范畴了,具体学习可以到:https://gorm.io/zh_CN/docs/migration.html
 
这边目前只起搭建项目目的,所以只建了一个user,后续的顺序是:创建一个user.go -》 migration.go 运行
user.go
type User struct {
	gorm.Model
	UserName       string `gorm:"unique"`
	PasswordDigest string
}
const (
	// PassWordCost 密码加密难度
	PassWordCost = 12
)
func (user *User) SetPassword(password string) error {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), PassWordCost)
	if err != nil {
		return err
	}
	user.PasswordDigest = string(bytes)
	return nil
}
func (user *User) CheckPassword(password string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(user.PasswordDigest), []byte(password))
	return err == nil
}
migration.go
//执行数据迁移
func migration() {
	// 自动迁移模式
	_ = DB.AutoMigrate(&User{})
}
此时运行main函数就可以看到底层数据库已经创建了user的数据表
此时的user.go也就是Java中的DO对象
搭建VO对象
这时我们只要创建两个文件即可:
- common.go 专门返回给前端的response二次封装
 - user.go 这里是返回给前端哪些需要的用户数据
 
common.go,直接复制脚手架的文件,然后再加点自己的状态码,返回类型就有以下内容:
下面的内容主要都是包含状态码和返回内容(Java中对json的二次封装,比如返回要返回状态码200还是500,返回什么内容)
// Response 基础序列化器
type Response struct {
	Code  int         `json:"code"`
	Data  interface{} `json:"data,omitempty"`
	Msg   string      `json:"msg"`
	Error string      `json:"error,omitempty"`
}
// TrackedErrorResponse 有追踪信息的错误响应
type TrackedErrorResponse struct {
	Response
	TrackID string `json:"track_id"`
}
//TokenData 带有token的Data结构
type TokenData struct {
	User  interface{} `json:"user"`
	Token string      `json:"token"`
}
type ResponseUser struct {
	Status int    `json:"status" example:"200"`
	Data   User   `json:"data"`
	Msg    string `json:"msg" example:"ok"`
	Error  string `json:"error" example:""`
}
// 三位数错误编码为复用http原本含义
// 五位数错误编码为应用自定义错误
// 五开头的五位数错误编码为服务器端错误,比如数据库操作失败
// 四开头的五位数错误编码为客户端错误,有时候是客户端代码写错了,有时候是用户操作错误
const (
	// CodeCheckLogin 未登录
	CodeCheckLogin = 401
	// CodeNoRightErr 未授权访问
	CodeNoRightErr = 403
	// CodeDBError 数据库操作失败
	CodeDBError = 50001
	// CodeEncryptError 加密失败
	CodeEncryptError = 50002
	//CodeParamErr 各种奇奇怪怪的参数错误
	CodeParamErr               = 40001
	ErrorExistUser             = 10002 //成员错误
	ErrorNotExistUser          = 10003
	ErrorFailEncryption        = 10006
	ErrorNotCompare            = 10007
	ErrorAuthCheckTokenFail    = 30001 //token 错误
	ErrorAuthCheckTokenTimeout = 30002 //token 过期
	ErrorAuthToken             = 30003
	ErrorAuth                  = 30004
	ErrorDatabase              = 40001
)
// CheckLogin 检查登录
func CheckLogin() Response {
	return Response{
		Code: CodeCheckLogin,
		Msg:  "未登录",
	}
}
// Err 通用错误处理
func Err(errCode int, msg string, err error) Response {
	res := Response{
		Code: errCode,
		Msg:  msg,
	}
	// 生产环境隐藏底层报错
	if err != nil && gin.Mode() != gin.ReleaseMode {
		res.Error = err.Error()
	}
	return res
}
// DBErr 数据库操作失败
func DBErr(msg string, err error) Response {
	if msg == "" {
		msg = "数据库操作失败"
	}
	return Err(CodeDBError, msg, err)
}
// ParamErr 各种参数错误
func ParamErr(msg string, err error) Response {
	if msg == "" {
		msg = "参数错误"
	}
	return Err(CodeParamErr, msg, err)
}
user.go
主要是封装返回给前端的字段,一开始看也很懵,为什么这个字段感觉和model里的User一样还要写一遍?而且下面的BuildUser是个什么鬼?
- 这里解答:BuildUser是在等下的service层会用到,是用来给jwt的
 
type User struct {
	ID       uint   `json:"id" form:"id" example:"1"`                    // 用户ID
	UserName string `json:"user_name" form:"user_name" example:"FanOne"` // 用户名
	CreateAt int64  `json:"create_at" form:"create_at"`                  // 创建
}
func BuildUser(user model.User) User {
	return User{
		ID:       user.ID,
		UserName: user.UserName,
		CreateAt: user.CreatedAt.Unix(),
	}
}
搭建Service层
有了上述的基础,就可以搭建service层了,在TodoList项目中的日志框架用了第三方的框架,本次教程则采用原生在util文件下封装的框架作为日志框架。
下面的这段代码实在理解不了可以直接照着抄一下:
type UserService struct {
	UserName string `form:"user_name" json:"user_name" binding:"required,min=3,max=15" example:"FanOne"`
	Password string `form:"password" json:"password" binding:"required,min=5,max=16" example:"FanOne666"`
}
func (service *UserService) Register() *serializer.Response {
	var user model.User
	var count int64
	model.DB.Model(&model.User{}).Where("user_name=?", service.UserName).First(&user).Count(&count)
	if count == 1 {
		code := serializer.ErrorExistUser
		return &serializer.Response{
			Code: code,
			Msg:  "成员错误",
		}
	}
	user.UserName = service.UserName
	//加密密码
	if err := user.SetPassword(service.Password); err != nil {
		util.Log().Error(err.Error())
		code := serializer.ErrorFailEncryption
		return &serializer.Response{
			Code: code,
			Msg:  "加密失败",
		}
	}
	//创建用户
	if err := model.DB.Create(&user).Error; err != nil {
		util.Log().Error(err.Error())
		code := serializer.ErrorDatabase
		return &serializer.Response{
			Code: code,
			Msg:  "创建用户失败",
		}
	}
	return &serializer.Response{
		Code: http.StatusOK,
		Msg:  "成功创建",
	}
}
func (service *UserService) Login() serializer.Response {
	var user model.User
	code := http.StatusOK
	if err := model.DB.Where("user_name=?", service.UserName).First(&user).Error; err != nil {
		// 查询不到,返回错误
		if errors.Is(err, gorm.ErrRecordNotFound) {
			util.Log().Error(err.Error())
			code = serializer.ErrorExistUser
			return serializer.Response{
				Code: code,
				Msg:  "用户不存在!",
			}
		}
		util.Log().Info(err.Error())
		return serializer.DBErr("", err)
	}
	if user.CheckPassword(service.Password) == false {
		code := serializer.ErrorNotCompare
		return serializer.Response{
			Code: code,
			Msg:  "密码错误",
		}
	}
	token, err := util.GenerateToken(user.ID, service.UserName, 0)
	if err != nil {
		util.Log().Error(err.Error())
		code = serializer.ErrorAuthToken
		return serializer.Response{
			Code: code,
			Msg:  "Token授权失败",
		}
	}
	return serializer.Response{
		Code: code,
		Data: serializer.TokenData{User: serializer.BuildUser(user), Token: token},
		Msg:  "登录成功!",
	}
}
唯一要注意的就是,在登录用户的模块中,需要使用到JWT框架,如下所示:
token, err := util.GenerateToken(user.ID, service.UserName, 0)
	if err != nil {
		util.Log().Error(err.Error())
		code = serializer.ErrorAuthToken
		return serializer.Response{
			Code: code,
			Msg:  "Token授权失败",
		}
	}
go-jwt好像换维护者了,地址:https://github.com/golang-jwt/jwt,用法也稍有不同。
go get -u github.com/golang-jwt/jwt/v4
下面这段代码是对TodoList util 中的jwt.go进行一些修改:
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
type Claims struct {
	Id        uint   `json:"id"`
	Username  string `json:"username"`
	Authority int    `json:"authority"`
	jwt.RegisteredClaims
}
//GenerateToken 签发用户Token
func GenerateToken(id uint, username string, authority int) (string, error) {
	nowTime := time.Now()
	expireTime := nowTime.Add(24 * time.Hour)
	claims := Claims{
		Id:        id,
		Username:  username,
		Authority: authority,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expireTime),
			Issuer:    "to-do-list",
		},
	}
	tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	token, err := tokenClaims.SignedString(jwtSecret)
	return token, err
}
//ParseToken 验证用户token
func ParseToken(token string) (*Claims, error) {
	tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		return jwtSecret, nil
	})
	if tokenClaims != nil {
		if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid {
			return claims, nil
		}
	}
	return nil, err
}
封装了之后,在登录用户模块就可以使用JWT了。
登录模块的最后可以看到:
return serializer.Response{
		Code: code,
		Data: serializer.TokenData{User: serializer.BuildUser(user), Token: token},
		Msg:  "登录成功!",
	}
里面就用到:
serializer.BuildUser(user)
这就是刚刚在封装的BuildUser
Controller层
最后,你可以直接复制Todo List项目中的api下的user.go,因为你已经从实体类层一直搭建到这里,肯定知道里面的原理,当然,你也可以直接复制下面我写的代码:
var userRegisterService service.UserService
func UserRegister(c *gin.Context) {
	if err := c.ShouldBind(&userRegisterService); err == nil {
		res := userRegisterService.Register()
		c.JSON(200, res)
	} else {
		c.JSON(400, ErrorResponse(err))
		util.Log().Error(err.Error())
	}
}
func UserLogin(c *gin.Context) {
	var userLoginService service.UserService
	if err := c.ShouldBind(&userLoginService); err == nil {
		res := userLoginService.Login()
		c.JSON(200, res)
	} else {
		c.JSON(400, ErrorResponse(err))
		util.Log().Error(err.Error())
	}
}
注:如果你是复制TodoList项目中的代码话,请忽略注释里面的内容,因为那个是swagger必须要写的
同时,api/common还有一个文件是common.go,这个在本次项目中是封装返回错误信息的controller,直接复制也可以
//返回错误信息 ErrorResponse
func ErrorResponse(err error) serializer.Response {
	if ve, ok := err.(validator.ValidationErrors); ok {
		for _, e := range ve {
			field := conf.T(fmt.Sprintf("Field.%s", e.Field))
			tag := conf.T(fmt.Sprintf("Tag.Valid.%s", e.Tag))
			return serializer.Response{
				Code:  40001,
				Msg:   fmt.Sprintf("%s%s", field, tag),
				Error: fmt.Sprint(err),
			}
		}
	}
	if _, ok := err.(*json.UnmarshalTypeError); ok {
		return serializer.Response{
			Code:  40001,
			Msg:   "JSON类型不匹配",
			Error: fmt.Sprint(err),
		}
	}
	return serializer.Response{
		Code:  40001,
		Msg:   "参数错误",
		Error: fmt.Sprint(err),
	}
}
搭建路由
当你搭建完上述的项目,肯定还启动不起来,gin不知道你访问的地址是个什么东西,所以用gin.engine搭建路由:
这里先把swagger的写上去,等下要用到
func NewRouter() *gin.Engine {
	r := gin.Default()
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // 开启swag
	// 路由
	v1 := r.Group("/api/v1")
	{
		v1.GET("ping", func(c *gin.Context) {
			c.JSON(200, "success")
		})
		// 用户操作
		v1.POST("user/register", api.UserRegister)
		v1.POST("user/login", api.UserLogin)
	}
	return r
}
临时测试
当搭建好路由以后,就可以在main函数上告诉gin你的url,你就可以访问到地址了
func main() {
	conf.Init()
	r := routes.NewRouter()
	_ = r.Run(conf.Port)
}
搭建swagger进行测试
完全可以按照:https://github.com/swaggo/swag/blob/master/README_zh-CN.md,进行操作。
当然有几个坑要注意:
- 在main函数中你可能会写到:
 
// programatically set swagger info
docs.SwaggerInfo.Title = "Todo List"
docs.SwaggerInfo.Description = "This is a simple ToDoList App."
docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Host = "localhost:8080"
docs.SwaggerInfo.BasePath = "/api/v1"
docs.SwaggerInfo.Schemes = []string{"http", "https"}
请组合以把导入的docs改一下,比如我初始化go项目的时候是:go mod init TodoList,那么我这里导入的docs
import ( "TodoList/conf" "TodoList/docs" "TodoList/routes" )
- 在controller层写go doc的时候是函数的名字!
 
// UserRegister godoc
// @Summary      用户注册
// @Description  用户注册
// @Tags         USER
// @Accept       json
// @Produce      json
// @Param        data  body      service.UserService      true  "用户名, 密码"
// @Success      200  {object}  serializer.ResponseUser  "{"status":200,"data":{},"msg":"ok"}"
// @Failure      500  {object}  serializer.ResponseUser  "{"status":500,"data":{},"Msg":{},"Error":"error"}"
// @Router       /user/register [post]
func UserRegister(c *gin.Context) {
    ....
}
然后后续测试就可以看到成功的标志,也可以继续学习完TodoList这个项目了:

后记
一开始做这个项目的时候十分艰难,Java架构思想根深蒂固(一开始看到那个logger整个人都不好,又不会用依赖注入,又想用zap替代,最后搞来搞去还是回归到了原生logger),看到Gin这个架构满身的细胞都在抗拒,后面又做了几天又感觉自己又行了,整个过程像在做过山车一样!
外传:项目实践:单例爬虫、协程并发爬虫
使用说明
文件名称:Spiders
初始化mod
go mod init xxx
安装goquery库(仅微博使用)
新浪微博
单例爬虫(需要cookie)
美之图
高并发协程爬虫