我使用 Java 很多年了,我非常喜欢 Java 及其生态系统。在 Java 生态系统中,Spring Boot 是我构建 Java 应用的首选框架。
前不久,我在一个项目中使用了 Golang,起初我对它的感觉褒贬不一。但用得越多,就越喜欢它。
每当我尝试学习一种新的语言或框架时,我都会尝试将新框架/语言的概念映射到我已经熟悉的框架/语言中。这有助于我更快地理解新框架/语言的生态系统。
学习任何新知识的最好方法就是用它来构建一些东西。因此,在本文中,我将带你了解如何使用 Go 构建一个 REST API,包括配置管理、日志记录、数据库访问等各个方面。
本文并不会涉及到 Golang 的基础知识,如如何声明变量、循环、函数等。
使用的库 {#使用的库}
Go 没有类似 Spring Boot 的框架。通常,Go 开发人员喜欢使用标准库,只添加必要的库来构建应用。
本文将会使用到以下库来在 Go 中构建一个 REST API:
- Gin Web Framework - Web 框架
- Viper - 配置库
- zap - 日志库
- pgx - Go 的 PostgreSQL 驱动程序和工具包
- golang-migrate - 数据迁移
安装 Go 和工具 {#安装-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 服务器方式运行应用 {#以-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 - Go 应用的热重载
- 使用 Taskfile 进行热重载 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 框架 {#使用-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 管理应用配置 {#使用-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 进行日志记录 {#使用-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 整合数据库 {#使用-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
来处理它们。
啰嗦,但也好理解。
抱歉,忍不住加了这个表情包。😄
现在,更新 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,其中包含应用的关键组件,即 GinRouter
和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 进行数据迁移 {#使用-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 文件 {#在二进制文件中嵌入非-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 后,现在,来实现创建新 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 端点与这些 API 实现非常相似。因此,剩下的一些端点,本文不再多说。你可以在 GitHub 仓库中找到完整的代码。
将 Go 应用 Docker 化 {#将-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 的比较 {#javaspringboot-与-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/