写给 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 CentralNPM 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 管理等。

还有一些其他的轻量级替代品,如 EchoFiberChi 等。但我更喜欢使用 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.Hmap[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.propertiesapplication.yml 文件中配置属性,并使用 @ConfigurationProperties 对其进行注解,从而将它们绑定到对象上。

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

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

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

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

我更喜欢使用 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 默认使用 Slf4jLogback 自动配置日志。如果想切换到不同的日志实现,如 log4j2,只需排除默认日志实现并添加新的即可。此外,还可以使用 application.propertiesapplication.yml 文件配置日志。

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

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

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

使用以下命令在项目中添加 zaplumberjack 依赖:

$ 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 和错误信息。
  • 对一系列错误进行检查,并通过返回 []Bookmarkerror 值中的 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。

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

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

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

重构代码

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

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,其中包含两个字段 dblogger。然后,在 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 RouterAppConfig
  • 创建一个名为 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 中,可以使用 FlywayLiquibase 来管理数据库迁移。只需将迁移脚本放在预期的位置,框架就会处理剩下的工作。

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

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

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

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

在项目根目录下创建 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")
}

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

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

现在,连接数据库并删除 bookmarks 表,然后运行应用。你应该会看到 bookmarks 表已经创建,而且还有一个由 golang-migrate 创建的 schema_migrations 表,用于跟踪已应用的迁移。这与 Flywayflyway_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 镜像。你也可以使用 jibDockerfile 创建 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_HOSTDB_PORTDB_USERNAMEDB_PASSWORDDB_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/