写给 Java / Spring Boot 开发者的 Golang 教程

我使用 Java 很多年了,我非常喜欢 Java 及其生态系统。在 Java 生态系统中,Spring Boot 是我构建 Java 应用的首选框架。

前不久,我在一个项目中使用了 Golang,起初我对它的感觉褒贬不一。但用得越多,就越喜欢它。

每当我尝试学习一种新的语言或框架时,我都会尝试将新框架/语言的概念映射到我已经熟悉的框架/语言中。这有助于我更快地理解新框架/语言的生态系统。

学习任何新知识的最好方法就是用它来构建一些东西。因此,在本文中,我将带你了解如何使用 Go 构建一个 REST API,包括配置管理、日志记录、数据库访问等各个方面。

本文并不会涉及到 Golang 的基础知识,如如何声明变量、循环、函数等。

使用的库

Go 没有类似 Spring Boot 的框架。通常,Go 开发人员喜欢使用标准库,只添加必要的库来构建应用。

本文将会使用到以下库来在 Go 中构建一个 REST API:

安装 Go 和工具

你可以从 https://go.dev/doc/install 下载并安装 Go。安装完成后,将 Go bin 目录添加到 PATH 环境变量中。

export GOPATH=$HOME/go
export PATH="$PATH:$GOPATH/bin"

你可以使用 VS Code、IntelliJ IDEA Ultimate(使用 Go 插件)、GoLand 或任何其他 IDE 进行 Go 开发。

项目设置

我们将为一个简单的书签应用构建一个 REST API,公开 CRUD 端点。

创建一个新的项目目录,并初始化一个 Go 模块。

$ mkdir bookmarks
$ cd bookmarks
$ go mod init github.com/sivaprasadreddy/bookmarks

这里的 github.com/sivaprasadreddy/bookmarks 是模块名称/路径。它可以是任何有效的名称,如 bookmarks,但通常的做法是使用项目的源码仓库名称作为模块名称。

Go 没有像 Maven Central 或 NPM Registry 那样的中央仓库。Go 模块直接从源码仓库下载。因此,使用源码仓库名称作为模块名称是个不错的做法。

当你运行 go mod init 命令时,它会创建一个 go.mod 文件,内容如下:

module github.com/sivaprasadreddy/bookmarks

go 1.21

现在,在项目根目录下创建一个名为 main.go 的文件,内容如下:

package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

在 Go 中,应用的入口点是 main 包中的 main() 函数。

现在,使用以下命令运行应用:

$ go run main.go
Hello World!

你还可以构建应用以生成特定于操作系统的二进制可执行文件,并使用该二进制文件运行应用,具体如下:

$ go build
$ ./bookmarks
Hello World!

也可以使用 go build -o binary-name 指定可执行二进制文件的名称。

以 Web 服务器方式运行应用

Go 标准库提供了 net/http 模块,你可以用它来构建 HTTP 服务器。更新 main.go 文件如下:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    mux := &http.ServeMux{}
    mux.HandleFunc("/hello", hello)
    log.Fatal(http.ListenAndServe(":8080", mux))
}

func hello(w http.ResponseWriter, r *http.Request) {
    _, err := fmt.Fprintln(w, "Hello World!")
    if err != nil {
        log.Println("Error processing the request")
    }
}

如上,使用 http.ServeMux 注册 Request Handler(请求处理器),并在 8080 端口启动服务器。

虽然这只是一个简单的例子,但也有很多值得注意的地方:

  • 在 Go 中,函数或结构体字段的可见性取决于标识符的首字母。如果第一个字母是大写,那么它是导出的,在包外可见。如果第一个字母是小写,则是私有的,在 package 外不可见。因此,hello 函数没有导出,在 main package 之外也不可见。
  • Go 函数可以返回多个值。在上例中,fmt.Fprintln() 函数返回两个值:写入的字节数和 Error(错误)。这里,我们忽略写入的字节数,只检查错误。
  • 常见的做法是将 Error 作为函数的最后一个值返回。
  • Go 没有类似于 Java 中的异常处理体系。因此,你需要明确地处理 Error。

现在,使用 go run main.go 运行应用,并在浏览器中访问 URL http://localhost:8080/hello。你应该会看到响应内容: Hello World!。

热重载

接下来,我们要把响应文本从 Hello World! 改为 Hello Go!。要使修改生效,需要重启应用。在开发过程中,每次更改代码都要重启应用会很麻烦。

在 Go 中实现热重载的方法不多。

我更喜欢使用 Air。你可以使用以下命令安装 Air:

$ go install github.com/cosmtrek/air@latest
$ air -v

我们可以使用 air init 创建一个默认的 air 配置文件,它将在项目根目录中创建一个名为 .air.toml 的文件,其中包含合理的默认值。然后,只需运行 air 命令即可启动应用。

$ air init
$ air

现在,将响应文本从 Hello World! 更改为 Hello Go! 并保存文件。刷新浏览器,就能看到更新后的响应。

使用 Gin Web 框架

虽然 Go 标准库中的 net/http 包足以构建简单的 HTTP 服务器,但其功能有限。因此,我们使用了 Gin Web 框架,它提供了很多有用的功能,如路由、JSON 验证、Error 管理等。

还有一些其他的轻量级替代品,如 Echo、Fiber、Chi 等。但我更喜欢使用 Gin,因为它是最受欢迎的,而且功能丰富。

使用以下命令将 Gin 依赖添加到我们的项目中:

$ go get -u github.com/gin-gonic/gin

运行此命令后,gin 模块将被下载并添加到 go.mod 文件中。

更新 main.go 文件如下

package main

import (
	"github.com/gin-gonic/gin"
	"log"
	"net/http"
)

func main() {
	r := gin.Default()
	r.GET("/hello", hello)
	log.Fatal(r.Run(":8080"))
}

func hello(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "Hello World",
	})
}

如上,使用 gin.Default() 创建了一个 Gin router,并设置了一个 Handler Function 来处理 GET /hello 请求。在 Handler 中,使用 c.JSON() 方法返回 JSON 响应。gin.H 是 map[string]interface{} 的快捷方式(type)。

启动程序前,运行 go mod tidy 命令。该命令会添加代码中使用但未在 go.mod 文件中声明的缺失模块依赖项。如果存在未使用的依赖项,go mod tidy 会相应地从 go.mod 中删除这些依赖项。

如果查看一下 go.mod 文件,就会发现依赖项被添加到了两个部分。第一个 require 部分包括应用代码使用的直接依赖项。第二个 require 部分包括 package 使用的间接依赖。

它还会创建或更新 go.sum 文件,其中包含每个依赖添加到模块时的确切内容的校验和。你可以将其视为 Node.js 中的 package-lock.json 文件。

$ go mod tidy
$ air

使用 Viper 管理应用配置

任何非复杂的应用都需要一些配置,如数据库连接详情、API Key 等。在 Spring Boot 中,这可以在 application.properties 或 application.yml 文件中配置属性,并使用 @ConfigurationProperties 对其进行注解,从而将它们绑定到对象上。

在 Go 中,有许 多第三方库 可用于配置管理。其中比较流行的有 godotenv、Viper、envconfig 等。其中,我最喜欢 Viper,因为它非常灵活且功能丰富。

使用以下命令将 Viper 添加到我们的项目中:

$ go get -u github.com/spf13/viper

我希望有一个默认的配置文件,并能通过环境变量覆盖属性。Viper 开箱即支持这一点,而且还能处理不同的文件格式,如 json、yaml 等。

我更喜欢使用 JSON 格式的配置文件。因此,让我们在项目根目录下创建一个名为 config.json 的文件,内容如下:

{
  "environment": "dev",
  "server_port": 8080,
  "logging": {
    "filename": "bookmarks.log",
    "level": "debug"
  },
  "db": {
    "host": "localhost",
    "port": 15432,
    "username": "postgres",
    "password": "postgres",
    "database": "postgres"
  }
}

现在,在 internal/config 目录下创建一个名为 config.go 的文件,内容如下:

package config

import (
	"github.com/spf13/viper"
	"log"
	"strings"
)

type AppConfig struct {
	Environment string   `mapstructure:"environment"`
	ServerPort  int      `mapstructure:"server_port"`
	Logging     Logging  `mapstructure:"logging"`
	Db          DbConfig `mapstructure:"db"`
}

type Logging struct {
	FileName string `mapstructure:"filename"`
	Level    string `mapstructure:"level"`
}

type DbConfig struct {
	Host     string `mapstructure:"host"`
	Port     int    `mapstructure:"port"`
	UserName string `mapstructure:"username"`
	Password string `mapstructure:"password"`
	Database string `mapstructure:"database"`
}

func GetConfig(configFilePath string) (AppConfig, error) {
	log.Printf("Config File Path: %s\n", configFilePath)
	conf := viper.New()
	conf.SetConfigFile(configFilePath)
	replacer := strings.NewReplacer(".", "_")
	conf.SetEnvKeyReplacer(replacer)
	conf.AutomaticEnv()

	err := conf.ReadInConfig()
	if err != nil {
		log.Printf("error reading config file: %v\n", err)
	}
	var cfg AppConfig

	err = conf.Unmarshal(&cfg)
	if err != nil {
		log.Printf("configuration unmarshalling failed!. Error: %v\n", err)
		return cfg, err
	}
	return cfg, nil
}
  • Go 没有 Class。相反,它有用于定义数据结构的 struct 结构体。
  • 创建了一个名为 AppConfig 的 struct,它代表应用配置。
  • 使用 mapstructure 标签将 json 属性路径映射到 AppConfig struct 字段。
  • 在配置 viper 时,可以用 DB_HOST 环境变量替换 db.host 属性值。
  • AutomaticEnv() 方法会自动读取环境变量。
  • 使用 conf.Unmarshal() 方法将配置值绑定到 AppConfig struct 中。
  • 最后,GetConfig() 方法是导出的(大写字母开头),并在 config 包之外可见。

Go 中的 internal 包

需要记住的重要一点是,在 Go 中,某些包的名称具有特殊含义。如果你将一个包命名为 internal,这意味着该包只对同一模块内的其他包可见。更多详情,请参阅 Internal 包。

现在,更新 main.go 文件如下:

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"log"
	"net/http"
)

func main() {
	cfg, err := config.GetConfig("config.json")
	if err != nil {
		log.Fatal(err)
	}
	r := gin.Default()
	r.GET("/hello", hello)
	log.Fatal(r.Run(fmt.Sprintf(":%d", cfg.ServerPort)))
}

func hello(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "Hello World",
	})
}

使用 AppConfig struct 中的值,而不是硬编码端口号。

现在,如果你更改了 config.json 文件中的端口号,你可能会希望 air 自动重启应用。但你需要在 .air.toml 文件的 include_ext 数组中添加 json 扩展名,如下所示:

  include_ext = ["go", "tpl", "tmpl", "html", "json"]

现在,需要手动重启应用,以便 air 从 .air.toml 文件中获取新配置。

使用 zap 进行日志记录

同样,在 Spring Boot 中,这个问题也迎刃而解。Spring Boot 默认使用 Slf4j 和 Logback 自动配置日志。如果想切换到不同的日志实现,如 log4j2,只需排除默认日志实现并添加新的即可。此外,还可以使用 application.properties 或 application.yml 文件配置日志。

Go 还有一个名为 log 的标准库包,可用于记录日志。不过,它非常基本,功能不多。Go 有许多第三方日志库,如 zap、zerolog 等。受到这些库的启发,Go 1.21 引入了一个名为 slog 的新包,以支持结构化日志。

Zap 是一个非常流行的日志库(Uber 开源),它被广泛使用并提供了很多功能。因此,本例也将使用它。

配置应用日志,将日志记录到文件和控制台。此外,还需要使用 lumberjack 库进行日志轮换。

使用以下命令在项目中添加 zap 和 lumberjack 依赖:

$ go get -u go.uber.org/zap
$ go get -u gopkg.in/natefinch/lumberjack.v2

现在,在 internal/logger 目录中创建一个名为 logger.go 的文件,内容如下:

package config

import (
	"os"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"gopkg.in/natefinch/lumberjack.v2"
)

type Logger struct {
	*zap.SugaredLogger
}

func NewLogger(cfg AppConfig) *Logger {
	logFile := cfg.Logging.FileName
	logLevel, err := zap.ParseAtomicLevel(cfg.Logging.Level)
	if err != nil {
		logLevel = zap.NewAtomicLevelAt(zap.InfoLevel)
	}
	hook := lumberjack.Logger{
		Filename:   logFile,
		MaxSize:    1024,
		MaxBackups: 30,
		MaxAge:     7,
		Compress:   true,
	}

	encoder := getEncoder()
	core := zapcore.NewCore(
		encoder,
		zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(&hook)),
		logLevel)
	options := []zap.Option{
		zap.AddCaller(),
		zap.AddStacktrace(zap.ErrorLevel),
	}
	if cfg.Environment != "prod" {
		options = append(options, zap.Development())
	}
	sugaredLogger := zap.New(core, options...).With(zap.String("env", cfg.Environment)).Sugar()
	return &Logger{sugaredLogger}
}

func getEncoder() zapcore.Encoder {
	return zapcore.NewJSONEncoder(zapcore.EncoderConfig{
		TimeKey:        "ts",
		LevelKey:       "level",
		NameKey:        "logger",
		CallerKey:      "caller",
		FunctionKey:    zapcore.OmitKey,
		MessageKey:     "msg",
		StacktraceKey:  "stacktrace",
		LineEnding:     zapcore.DefaultLineEnding,
		EncodeLevel:    zapcore.LowercaseLevelEncoder,
		EncodeTime:     zapcore.ISO8601TimeEncoder,
		EncodeDuration: zapcore.SecondsDurationEncoder,
		EncodeCaller:   zapcore.ShortCallerEncoder,
	})
}

虽然看起来代码很多,但其实就是配置 Encoder 在日志中包含哪些细节。此外,还使用了 AppConfig struct 中的日志文件名和日志级别。

现在更新 main.go 以使用 logger,如下所示:

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"log"
	"net/http"
)

func main() {
	cfg, err := config.GetConfig("config.json")
	if err != nil {
		log.Fatal(err)
	}
	logger := config.NewLogger(cfg)
	logger.Infof("Application is running on %d", cfg.ServerPort)
	r := gin.Default()
	r.GET("/hello", hello)
	log.Fatal(r.Run(fmt.Sprintf(":%d", cfg.ServerPort)))
}

func hello(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "Hello World",
	})
}

现在,如果运行应用,就会在控制台和 bookmarks.log 文件中看到以下日志信息。

{"level":"info","ts":"2023-11-18T12:11:51.091+0530","caller":"bookmarks/main.go:17","msg":"Application is running on 8080","env":"dev"}

接下来,整合数据库。

使用 pgx 整合数据库

Go 标准库提供了用于访问关系数据库的 database/sql 包。我们使用 PostgreSQL 作为数据库,并使用 pgx 驱动程序。

你可以使用下面的 docker-compose.yml 文件来启动 PostgreSQL 数据库(Docker):

version: '3.8'
services:

  bookmarks-db:
    image: postgres:16-alpine
    container_name: bookmarks-db
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=postgres
    ports:
      - "15432:5432"
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready -U postgres" ]
      interval: 10s
      timeout: 5s
      retries: 5

使用 docker compose up -d 命令启动数据库容器,连接数据库,并使用以下脚本创建 bookmarks 表和示例数据:

create table bookmarks
(
    id         bigserial primary key,
    title      varchar   not null,
    url        varchar   not null,
    created_at timestamp
);

INSERT INTO bookmarks (title, url, created_at) 
VALUES ('SivaLabs Blog', 'https://sivalabs.in', CURRENT_TIMESTAMP);

使用以下命令将 pgx 依赖添加到项目中:

$ go get -u github.com/jackc/pgx/v5

首先,在 internal/config 目录下创建一个名为 db.go 的文件,内容如下:

package config

import (
	"context"
	"fmt"
	"github.com/jackc/pgx/v5"
	"log"
)

func GetDb(config AppConfig) *pgx.Conn {
	connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
		config.Db.Host, config.Db.Port, config.Db.UserName, config.Db.Password, config.Db.Database)
	conn, err := pgx.Connect(context.Background(), connStr)
	if err != nil {
		log.Fatal(err)
	}
	return conn
}

这里没有什么突破性的东西。传递 AppConfig 结构,并使用数据库配置创建连接字符串。然后使用 pgx.Connect() 方法创建数据库连接。如果连接数据库失败,就记录错误并退出应用。

接下来,在 main.go 文件中创建一个 struct 来表示书签(Bookmark),如下所示:

type Bookmark struct {
	ID        int
	Title     string
	Url       string
	CreatedAt time.Time
}

现在,在 main.go 文件中实现一个函数,从数据库中获取所有书签,如下所示:

func getAll(ctx context.Context, db *pgx.Conn) ([]Bookmark, error) {
	query := `select id, title, url, created_at FROM bookmarks`
	rows, err := db.Query(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var bookmarks []Bookmark
	for rows.Next() {
		var bookmark = Bookmark{}
		err = rows.Scan(&bookmark.ID, &bookmark.Title, &bookmark.Url, &bookmark.CreatedAt)
		if err != nil {
			return nil, err
		}
		bookmarks = append(bookmarks, bookmark)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return bookmarks, nil
}

习惯于使用 Spring Data JPA 和简单调用 bookmarkRepository.findAll() 方法的人可能会觉得这段代码有点冗长。我花了一段时间才习惯这种 Go 代码风格(Err Lang)。

  • 使用 pgx.Conn 对象执行查询并获取结果集。
  • 使用 rows.Next() 方法遍历结果集。
  • 使用 rows.Scan() 方法将结果集映射到 Bookmark struct。
  • 使用 rows.Err() 方法在遍历结果集时检查错误。
  • 使用 defer 关键字在函数执行结束时关闭结果集。
  • 从函数中返回 []Bookmark slice 和错误信息。
  • 对一系列错误进行检查,并通过返回 []Bookmark 和 error 值中的 nil 来处理它们。

啰嗦,但也好理解。

使用 Golang 后,有人给我说 Java 很啰嗦

抱歉,忍不住加了这个表情包。😄

现在,更新 main.go 文件,为 GET /api/bookmarks 端点添加一个 Handler,如下所示:

package main

import (
	"context"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/jackc/pgx/v5"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"log"
	"net/http"
	"time"
)

func main() {
	cfg, err := config.GetConfig("config.json")
	if err != nil {
		log.Fatal(err)
	}
	logger := config.NewLogger(cfg)
	db := config.GetDb(cfg)

	r := gin.Default()
	r.GET("/api/bookmarks", getAllBookmarks(db, logger))
	log.Fatal(r.Run(fmt.Sprintf(":%d", cfg.ServerPort)))
}

func getAllBookmarks(db *pgx.Conn, logger *config.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		ctx := c.Request.Context()
		bookmarks, err := getAll(ctx, db)
		if err != nil {
			logger.Errorf("Error fetching bookmarks from db: %v", err)
			c.JSON(http.StatusInternalServerError, gin.H{
				"error": "Failing to fetch bookmarks",
			})
		}
		c.JSON(http.StatusOK, bookmarks)
	}
}

// Bookmark struct
// func getAll(ctx context.Context, db *pgx.Conn) ([]Bookmark, error) 

这里需要理解的关键部分是 getAllBookmarks 函数。通常,我们会创建签名为 func(c *gin.Context) 的 gin Handler 函数,并使用 r.GET("/api/bookmarks", getAllBookmarks) 将其设置为 Handler。

不过,需要将 db 和 logger 对象传递给 Handler 函数。因此,我们创建了一个名为 getAllBookmarks 的函数,该函数将 db 和 logger 对象作为参数,并返回一个签名为 func(c *gin.Context) 的函数。然后,使用 r.GET("/api/bookmarks", getAllBookmarks(db, logger)) 连接 Handler。

现在,运行应用并访问 URL http://localhost:8080/api/bookmarks,你应该能够看到一个 bookmark 响应。

虽然它能够工作,但我们把所有内容都放在了 main.go 文件中。没有关注点分离,而且将 db 和 logger 作为输入传递给所有函数并不好看。

重构代码

在重构代码之前,先了解几件事。

Go 中没有类(Class)的概念。取而代之的是用于定义数据结构的结构体 struct。我们可以在结构体上定义如下方法:

type BookmarkRepository {
	db *pgx.Conn
    logger *config.Logger
}

func (b BookmarkRepository) GetAll(ctx context.Context) ([]Bookmarks, error) {
	b.logger.Infof("Fetching all bookmarks")
	b.db.Query(...)
}

var bookmarkRepo = BookmarkRepository{db: db, logger: logger}
bookmarks, err := bookmarkRepo.GetAll(ctx)

如上,定义了一个名为 BookmarkRepository 的 struct,其中包含两个字段 db 和 logger。然后,在 BookmarkRepository 结构上定义了一个名为 GetAll 的方法。方法名称前的 (b BookmarkRepository) 是调用接收器(receiver),通过它可以访问 struct 的字段。

接下来,我们可能不想直接向外界暴露 BookmarkRepository struct。因此,可以创建一个接口(interface),并在接口上定义如下方法:

type BookmarkRepository interface {
	GetAll(ctx context.Context) ([]Bookmark, error)
}

然后,可以创建一个未导出的 struct(首字母小写)来实现接口。在 Go 中,你不需要显式地声明该 struct 实现了接口。如果 struct 拥有接口中定义的所有方法,那么它就会被自动视为实现了接口。

type bookmarkRepo struct {
	db     *gorm.DB
	logger *config.Logger
}

func NewBookmarkRepository(db *gorm.DB, logger *config.Logger) BookmarkRepository {
	return bookmarkRepo{
		db:     db,
		logger: logger,
	}
}

func (r bookmarkRepo) GetAll(ctx context.Context) ([]Bookmark, error) {
	r.db.Query(...)
}

// --------- usage ------------
var db = ...
var logger = ...
var bookmarkRepo = NewBookmarkRepository(db, logger)
bookmarks, err := bookmarkRepo.GetAll(ctx)

现在,重构代码,使用这种方式。

在 internal/domain 目录下创建名为 repository.go 的文件,内容如下:

package domain

import (
	"context"
	"github.com/jackc/pgx/v5"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"time"
)

type Bookmark struct {
	ID        int
	Title     string
	Url       string
	CreatedAt time.Time
}

type BookmarkRepository interface {
	GetAll(ctx context.Context) ([]Bookmark, error)
	GetByID(ctx context.Context, id int) (*Bookmark, error)
	Create(ctx context.Context, b Bookmark) (*Bookmark, error)
	Update(ctx context.Context, b Bookmark) error
	Delete(ctx context.Context, id int) error
}

type bookmarkRepo struct {
	db     *pgx.Conn
	logger *config.Logger
}

func NewBookmarkRepository(db *pgx.Conn, logger *config.Logger) BookmarkRepository {
	return bookmarkRepo {
		db:     db,
		logger: logger,
	}
}

func (r bookmarkRepo) GetAll(ctx context.Context) ([]Bookmark, error) {
	query := `select id, title, url, created_at FROM bookmarks`
	rows, err := r.db.Query(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var bookmarks []Bookmark
	for rows.Next() {
		var bookmark = Bookmark{}
		err = rows.Scan(&bookmark.ID, &bookmark.Title, &bookmark.Url, &bookmark.CreatedAt)
		if err != nil {
			return nil, err
		}
		bookmarks = append(bookmarks, bookmark)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return bookmarks, nil
}

func (r bookmarkRepo) GetByID(ctx context.Context, id int) (*Bookmark, error) {
	panic("implement me")
}

func (r bookmarkRepo) Create(ctx context.Context, b Bookmark) (*Bookmark, error) {
	panic("implement me")
}

func (r bookmarkRepo) Update(ctx context.Context, b Bookmark) error {
	panic("implement me")
}

func (r bookmarkRepo) Delete(ctx context.Context, id int) error {
	panic("implement me")
}

你可能想问为什么要将 context.Context 作为入参传递给所有方法?在 Go 中,你可以使用 context.Context 跨 API 边界传递 request scope 的值、取消信号和超时时间。更多详情,请参阅 Context 。

现在,重构 API Handler。

在 internal/api 目录下创建名为 handler.go 的文件,内容如下:

package api

import (
  "github.com/gin-gonic/gin"
  "github.com/sivaprasadreddy/bookmarks/internal/config"
  "github.com/sivaprasadreddy/bookmarks/internal/domain"
  "net/http"
)

type BookmarkController struct {
  repo   domain.BookmarkRepository
  logger *config.Logger
}

func NewBookmarkController(repo domain.BookmarkRepository, logger *config.Logger) BookmarkController {
  return BookmarkController{
    repo:   repo,
    logger: logger,
  }
}

func (p BookmarkController) GetAll(c *gin.Context) {
  p.logger.Info("Finding all bookmarks")
  ctx := c.Request.Context()
  bookmarks, err := p.repo.GetAll(ctx)
  if err != nil {
    if err != nil {
      p.logger.Errorf("Error :%v", err)
    }
    c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
      "error": "Unable to fetch bookmarks",
    })
    return
  }
  c.JSON(http.StatusOK, bookmarks)
}

最后,更新 main.go 文件,以使用这些更改,如下所示:

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sivaprasadreddy/bookmarks/internal/api"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"github.com/sivaprasadreddy/bookmarks/internal/domain"
	"log"
)

func main() {
	cfg, err := config.GetConfig("config.json")
	if err != nil {
		log.Fatal(err)
	}
	logger := config.NewLogger(cfg)
	db := config.GetDb(cfg)
	repo := domain.NewBookmarkRepository(db, logger)
	handler := api.NewBookmarkController(repo, logger)

	logger.Infof("Application is running on %d", cfg.ServerPort)
	r := gin.Default()
	r.GET("/api/bookmarks", handler.GetAll)
	log.Fatal(r.Run(fmt.Sprintf(":%d", cfg.ServerPort)))
}

现在,这看起来好多了。不过,来自 Spring Boot 背景的你可能想知道,我的依赖注入和其他很酷的 AOP 东西在哪里?

现在,看起来好多了。然而,如果你熟悉 Spring Boot,可能会想知道 “依赖注入(Dependency Injection)” 和其他酷炫的 “AOP” 功能在哪儿?

Go 没有内置的依赖注入支持。有一些第三方库可以实现依赖注入,比如 wire。但 Go 社区更倾向于保持简单,像上面那样手动创建 struct 并将它们设置在一起。

重构工作已接近尾声,但我还想更进一步。我希望尽可能减少 main.go 中的逻辑,并将应用初始化和启动服务器的工作委托给一个单独的包。

在 cmd 目录中创建一个名为 app.go 的文件,内容如下:

package cmd

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sivaprasadreddy/bookmarks/internal/api"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"github.com/sivaprasadreddy/bookmarks/internal/domain"
	"log"
)

type App struct {
	Router *gin.Engine
	Cfg    config.AppConfig
}

func NewApp(cfg config.AppConfig) *App {
	logger := config.NewLogger(cfg)
	db := config.GetDb(cfg)

	repo := domain.NewBookmarkRepository(db, logger)
	handler := api.NewBookmarkController(repo, logger)

	router := gin.Default()

	router.GET("/api/bookmarks", handler.GetAll)

	return &App{
		Cfg:    cfg,
		Router: router,
	}
}

func (app App) Run() {
	log.Fatal(app.Router.Run(fmt.Sprintf(":%d", app.Cfg.ServerPort)))
}
  • 创建了一个名为 App 的 struct,其中包含应用的关键组件,即 Gin Router 和 AppConfig。
  • 创建一个名为 NewApp() 的函数,它将 AppConfig 作为输入参数,初始化应用并返回 App struct。
  • 创建一个名为 Run 的方法,用于启动应用。

现在,更新 main.go 文件,以使用它,如下所示:

package main

import (
	"github.com/sivaprasadreddy/bookmarks/cmd"
	"github.com/sivaprasadreddy/bookmarks/internal/config"
	"log"
)

func main() {
	cfg, err := config.GetConfig("config.json")
	if err != nil {
		log.Fatal(err)
	}
	app := cmd.NewApp(cfg)
	app.Run()
}

现在,这看起来好多了。

使用 golang-migrate 进行数据迁移

在 Spring Boot 中,可以使用 Flyway 或 Liquibase 来管理数据库迁移。只需将迁移脚本放在预期的位置,框架就会处理剩下的工作。

在 Go 语言中,用于数据库迁移的库不多。其中,golang-migrate 是一个很受欢迎的库,它支持许多数据库。

使用以下命令在项目中添加 golang-migrate 依赖:

$ go get -u github.com/golang-migrate/migrate/v4

在使用 golang-migrate 时,我们要创建 up 和 down 迁移,以支持撤销更改。

在项目根目录下创建 db/migrations 目录。然后在 db/migrations 目录中创建一个名为 000001_init_schema.up.sql 的文件,内容如下:

create table bookmarks
(
    id         bigserial primary key,
    title      varchar   not null,
    url        varchar   not null,
    created_at timestamp
);

然后在 db/migrations 目录中创建一个名为 000001_init_schema.down.sql 的文件,内容如下:

drop table bookmarks;

你可以创建更多迁移脚本来插入样本数据等。

在实现应用 db 迁移的逻辑之前,首先需要了解一些关于在 Go 二进制文件中包含非 Go 文件的知识。

在二进制文件中嵌入非 Go 文件

在 Java 中,当构建 jar/war 文件时,默认情况下,放在 src/main/resources 中的所有静态资源都会打包到 jar/war 文件中。但在 Go 中,默认情况下只有编译过的 go 代码会成为二进制文件的一部分。在 Go 1.16 之前,需要使用一些第三方库将非 go 文件打包到二进制文件中。Go 1.16 引入了一项名为 “嵌入”(Embedding)的新功能,可以轻松地将非 go 文件包含到二进制文件中。

我们可以利用这一功能在二进制文件中包含迁移脚本。在二进制文件中包含与应用相关的所有内容,可以方便地部署和运行应用。

在 db 目录中创建名为 migrations.go 的文件,内容如下:

package db

import "embed"

//go:embed migrations/*.sql
var MigrationsFS embed.FS

如上,使用 //go:embed 指令将 SQL 迁移脚本嵌入 MigrationsFS。

现在,更新 internal/config/db.go 文件,按如下步骤运行迁移:

package config

import (
  "context"
  "fmt"
  "github.com/golang-migrate/migrate/v4"
  _ "github.com/golang-migrate/migrate/v4/database/postgres"
  "github.com/golang-migrate/migrate/v4/source/iofs"
  "github.com/jackc/pgx/v5"
  "github.com/sivaprasadreddy/bookmarks/db"
)

func GetDb(config AppConfig, logger *Logger) *pgx.Conn {
  connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
    config.Db.Host, config.Db.Port, config.Db.UserName, config.Db.Password, config.Db.Database)
  conn, err := pgx.Connect(context.Background(), connStr)
  if err != nil {
    logger.Fatal(err)
  }
  applyDbMigrations(config, logger)
  return conn
}

func applyDbMigrations(config AppConfig, logger *Logger) {
  d, err := iofs.New(db.MigrationsFS, "migrations")
  if err != nil {
    logger.Fatalf("Error while loading db migrations from sources: %v", err)
  }
  databaseURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
    config.Db.UserName, config.Db.Password, config.Db.Host, config.Db.Port, config.Db.Database)
  m, err := migrate.NewWithSourceInstance("iofs", d, databaseURL)
  if err != nil {
    logger.Fatalf("Error while loading db migrations: %v", err)
  }
  err = m.Up()
  if err != nil && !errors.Is(err, migrate.ErrNoChange) {
    logger.Fatalf("Error while applying db migrations: %v", err)
  }
  logger.Infof("Database migrations applied successfully")
}

我们从 MigrationsFS 的 migrations 目录中加载了迁移脚本,并将其应用到数据库中。注意,还需要导入 postgres 驱动,以便与 golang-migrate 配合使用。默认情况下,Go 不允许声明未使用的变量或 import。因此,必须使用 _ 来导入包,以避免出现错误。

此外,注意这里把 config.Logger 传递给了 GetDb() 函数。因此,也需要从 app.go 文件的 NewApp(cfg config.AppConfig) 函数中传递它。

现在,连接数据库并删除 bookmarks 表,然后运行应用。你应该会看到 bookmarks 表已经创建,而且还有一个由 golang-migrate 创建的 schema_migrations 表,用于跟踪已应用的迁移。这与 Flyway 的 flyway_schema_history 表类似,但并不完全相同。

实现 Bookmark 创建 API

实现了获取所有 Bookmark 的 API 后,现在,来实现创建新 Bookmark 的 API。

更新 internal/domain/repository.go 文件,更新 Create() 方法如下:

func (r bookmarkRepo) Create(ctx context.Context, b Bookmark) (*Bookmark, error) {
	query := "insert into bookmarks(title, url, created_at) values($1, $2, $3) RETURNING id"
	var lastInsertID int
	err := r.db.QueryRow(ctx, query, b.Title, b.Url, b.CreatedAt).Scan(&lastInsertID)
	if err != nil {
		r.logger.Errorf("Error while inserting bookmark: %v", err)
		return nil, err
	}
	b.ID = lastInsertID
	return &b, nil
}

现在,在 internal/api/handler.go 文件中添加一个创建新 Bookmark 的 Handler,如下所示:

type CreateBookmarkRequest struct {
	Title string `json:"title" binding:"required"`
	Url   string `json:"url" binding:"required,url"`
}

func (p BookmarkController) Create(c *gin.Context) {
	ctx := c.Request.Context()
	var model CreateBookmarkRequest
	if err := c.ShouldBindJSON(&model); err != nil {
        // you can extract error details as follows
        /*for _, err := range err.(validator.ValidationErrors) {
            fmt.Println(err.Field())
            fmt.Println(err.Tag())
            fmt.Println(err.Kind())
            fmt.Println(err.Type())
            fmt.Println(err.Value())
        }*/
		p.respondWithError(c, http.StatusBadRequest, err, "Invalid request payload")
		return
	}
	p.logger.Infof("Creating bookmark for URL: %s", model.Url)
	bookmark := domain.Bookmark{
		ID:        0,
		Title:     model.Title,
		Url:       model.Url,
		CreatedAt: time.Now(),
	}

	savedBookmark, err := p.repo.Create(ctx, bookmark)
	if err != nil {
		p.respondWithError(c, http.StatusInternalServerError, err, "Failed to create bookmark")
		return
	}
	c.JSON(http.StatusCreated, savedBookmark)
}

func (p BookmarkController) respondWithError(c *gin.Context, code int, err error, errMsg string) {
	if err != nil {
		p.logger.Errorf("Error :%v", err)
	}
	c.AbortWithStatusJSON(code, gin.H{
		"error": errMsg,
	})
}

我们创建了一个名为 CreateBookmarkRequest 的 struct 来表示请求体。添加了 json tag,以便将请求体映射到 struct 字段。此外,还添加了 binding tag 来验证请求体。Gin 使用 validator 包进行验证。你可以从注释代码中获取到校验错误的详细信息。

然后,添加了一个名为 respondWithError 的工具方法来统一处理错误响应。

最后,必须在 cmd/app.go 文件中将 Handler 设置到 Router,如下所示:

router.POST("/api/bookmarks", handler.Create)

现在,运行应用,并使用以下 curl 命令创建一个新 Bookmark:

curl --location --request POST 'http://localhost:8080/api/bookmarks' \
--header 'Content-Type: application/json' \
--data-raw '{
    "title": "Google",
    "url": "https://google.com"
}'

你应该可以看到如下响应:

{
    "ID": 1,
    "Title": "Google",
    "Url": "https://google.com",
    "CreatedAt": "2021-09-18T12:11:51.091+05:30"
}

注意,JSON Key 是 Bookmark struct 的字段名。可以使用如下 json tag 自定义响应:

type Bookmark struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Url       string    `json:"url"`
	CreatedAt time.Time `json:"createdAt"`
}

现在,你应该可以看到以下响应:

{
    "id": 1,
    "title": "Google",
    "url": "https://google.com",
    "createdAt": "2021-09-18T12:11:51.091+05:30"
}

实现其他 API 端点

至此,我们实现了获取所有书签和创建新书签的 API。其余的 API 端点与这些 API 实现非常相似。因此,剩下的一些端点,本文不再多说。你可以在 GitHub 仓库中找到完整的代码。

将 Go 应用 Docker 化

Spring Boot 内置支持使用 Buildpacks 创建 Docker 镜像。你也可以使用 jib 或 Dockerfile 创建 Docker 镜像。

我们可以使用下面的 Dockerfile 对 Go 应用进行 Docker 化:

FROM golang:1.21-buster as builder
# 创建并进入应用目录
WORKDIR /app
# 复制 go.mod 和 go.sum。
COPY go.* ./
# 下载所有依赖。如果 go.mod 和 go.sum 文件未作更改,依赖会被缓存
RUN go mod download
# 将本地代码复制到容器映像中
COPY . ./
# 构建 Go 应用
RUN GO111MODULE=on GOOS=linux CGO_ENABLED=0 go build -v -o server

######## 从 scratch 开始一个新 stage  #######
FROM gcr.io/distroless/base-debian10
WORKDIR /

# 复制前一阶段的预编译二进制文件 
COPY --from=builder /app/server ./server
COPY --from=builder /app/config.json ./config.json

# 在容器启动时运行服务
CMD ["/server"]

请注意,这里使用多阶段构建来创建 Docker 镜像。在第一阶段,使用 golang 官方镜像来构建应用并生成二进制文件。在第二阶段,使用无发行版镜像来运行应用。我们将二进制文件和 config.json 文件从第一阶段复制到第二阶段。最后,使用二进制文件启动应用。

我们可以使用环境变量覆盖 config.json 文件中定义的默认配置属性。例如,如果要覆盖服务器端口,可以向容器传递 SERVER_PORT 环境变量。你可以使用 DB_HOST、DB_PORT、DB_USERNAME、DB_PASSWORD 和 DB_DATABASE 环境变量传递数据库连接属性。

Java/SpringBoot 与 Go 的比较

每种语言和框架都有自己的优缺点,我们要做的就是选择合适的工具。没有灵丹妙药,也没有放之四海而皆准的解决方案。

有时性能是最重要的因素,有时开发人员的工作效率是最重要的因素。我们需要评估每种技术的优缺点,并针对手头的问题选择合适的技术。

Java/SpringBoot:

  • Java 有一个非常成熟的生态系统,有许多可用的库和工具。
  • Spring Boot 是一个 “约定大于配置” 的框架,它提供了许多开箱即用的功能。
  • Spring Boot 提供了许多开箱即用的常用功能,大大提高了开发人员的工作效率。
  • Spring Boot 的学习曲线非常陡峭,需要花费大量时间才能掌握。
  • 与 Go 相比,Spring Boot 需要消耗更多的资源(CPU、内存)。随着 GraalVM 原生镜像的支持,这种情况正在迅速改变。不过,还有很多库与 GraalVM 原生镜像不兼容,原生编译目前需要花费大量时间。

Go:

  • Go 是一种非常简单的语言,功能不多。
  • Go 是一种有一定强制约束的语言,它迫使你以特定的方式做事,比如格式化、未使用的变量等。
  • Go 拥有丰富的标准库和工具链(格式化、测试、基准测试、跨平台编译等)支持。
  • Go 语言冗长,与 Java 相比,实现同样的目标需要更多行代码。我觉得,这主要是因为 Go 的 Error 处理方式导致的。
  • 与 Java/SpringBoot 相比,Go 消耗的资源(CPU、内存)更少。
  • 在我看来,Go 的最大优势在于它的简单性。虽然 Go 代码看起来比较冗长,但却非常容易理解和维护。

Go 社区倾向于只使用必要的库并将它们集成在一起,而不是使用 Spring Boot 或 Django 这样的一体化框架。

我觉得,与 Java/SpringBoot 相比,Go 语言更加冗长,需要编写的代码行数也更多。但在使用 Go 代码时,认知负荷也会减少。

不过,一旦了解了 Spring Boot 背后的奥妙,构建应用就会变得非常高效。Spring Boot 已经解决了许多常见的应用需求,如配置管理、日志、监控等。你还可以找到 Spring Boot 与几乎所有领域的集成,这对快速构建应用大有帮助。

总结

我并不是想说服你哪一种更好。如果你打算用 Go 构建一个类似于 Java/Spring Boot 的应用,我希望这篇文章能对你有所帮助。

如果是 Spring Boot 开发者,那么你可能会发现要适应 Go 的工作方式有点困难。尤其是,Spring Boot 内置了很多功能和抽象,让开发人员的生活变得更轻松。但在 Go 中,你必须自己实现或集成各种库。

然而,一旦框架的基础结构准备好了,你就可以专注于业务逻辑的实现,而认知负荷却非常小。由于没有各种花里胡哨的注解和 N 层抽象,代码非常容易理解。

此外,Go 应用消耗的内存非常少,启动速度也非常快。在容器环境中,这一点非常重要。

本文还有很多内容没有涉及,比如优雅停机、监控、测试等。但我希望这篇文章能帮助你开始学习 Go。

本文中的完整代码可以在 Github 上找到,除了本文中所介绍的内容外还包括了如下:

  • 剩余的 API 端点
  • 优雅停机
  • 使用 GORM 实现 Repository

Ref:https://www.sivalabs.in/go-for-java-springboot-developers/