Go Gin MySQL Web API 开发教程

103 阅读11分钟

Go Gin MySQL Web API 项目开发教程

干中学。在 AI 的帮助下使用 Go 语言完成了后端项目的开发,项目已经成功上线运行。但面对实际代码,我仍感到一头雾水。还是需要 “先自己,后 AI” 的(基本)练习将夯实基础,提升独立开发能力。

📚 教程简介

本教程将带你从零开始构建一个完整的 Go Web API 项目,使用 Gin 框架和 MySQL 数据库。通过这个项目,你将学习到:

  • Go 语言 Web 开发基础
  • Gin 框架的使用
  • MySQL 数据库操作
  • 分层架构设计
  • RESTful API 设计
  • 项目结构组织

🎯 项目目标

我们将构建一个用户和文章管理系统,包含以下功能:

  • 用户注册、查询、更新、删除
  • 文章创建、查询、更新、删除
  • 用户与文章的关联查询
  • 数据库事务处理

📋 前置要求

在开始之前,请确保你已经安装了以下软件:

🚀 第一步:环境准备

1.1 验证 Go 环境

打开终端,验证 Go 是否正确安装:

go version

应该看到类似输出:

go version go1.24.0 darwin/amd64

1.2 设置 Go 工作区

# 创建项目目录
mkdir golang-gin-mysql-tutorial
cd golang-gin-mysql-tutorial

# 初始化 Go 模块
go mod init github.com/yourname/golang-gin-mysql

1.3 安装项目依赖

# 安装 Gin Web 框架
go get -u github.com/gin-gonic/gin

# 安装 MySQL 驱动
go get -u github.com/go-sql-driver/mysql

# 安装配置管理库
go get -u github.com/spf13/viper

🗄️ 第二步:数据库准备

2.1 创建数据库

打开 MySQL 客户端,执行以下 SQL 语句:

-- 创建数据库
CREATE DATABASE IF NOT EXISTS dev_learning 
CHARACTER SET utf8mb4 
COLLATE utf8mb4_unicode_ci;

-- 使用数据库
USE dev_learning;

2.2 创建数据表

-- 用户表
CREATE TABLE IF NOT EXISTS users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(64) NOT NULL UNIQUE,
    email VARCHAR(128) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 文章表(每篇文章属于一个用户)
CREATE TABLE IF NOT EXISTS articles (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

2.3 数据库设计说明

  • BIGINT UNSIGNED:防止负数溢出,提供更大的 ID 范围
  • ON DELETE CASCADE:删除用户时自动删除其文章,保证数据一致性
  • created_at/updated_at:自动维护时间戳,便于数据追踪
  • 外键约束:保证数据完整性,防止孤立数据

📁 第三步:项目结构设计

3.1 创建项目目录结构

mkdir -p cmd/server
mkdir -p internal/{config,db,handler,model,repository,service}
mkdir -p pkg

3.2 项目结构说明

golang-gin-mysql/
├── cmd/                    # 应用程序入口
│   └── server/
│       └── main.go        # 主程序入口
├── internal/              # 内部包(不对外暴露)
│   ├── config/            # 配置管理
│   ├── db/                # 数据库连接
│   ├── handler/           # HTTP 处理器
│   ├── model/             # 数据模型
│   ├── repository/        # 数据访问层
│   └── service/           # 业务逻辑层
├── pkg/                   # 公共包(可对外暴露)
├── config.yaml            # 配置文件
├── go.mod                 # Go 模块文件
└── README.md              # 项目说明

3.3 分层架构说明

  • Handler 层:处理 HTTP 请求和响应
  • Service 层:处理业务逻辑
  • Repository 层:处理数据访问
  • Model 层:定义数据结构

⚙️ 第四步:配置管理模块

4.1 创建配置文件

创建 config.yaml 文件:

# 应用程序配置文件
server:
  # 服务器监听端口
  port: ":8080"

mysql:
  # 数据库连接字符串
  dsn: "root:password@tcp(127.0.0.1:3306)/dev_learning?parseTime=true&loc=Local"

4.2 创建配置结构体

创建 internal/config/config.go

package config

import "github.com/spf13/viper"

// Config 应用程序配置结构体
type Config struct {
    Server struct {
        Port string `mapstructure:"port"`
    }
    MySQL struct {
        DSN string `mapstructure:"dsn"`
    }
}

// Load 加载配置文件
func Load(path string) (*Config, error) {
    v := viper.New()
    v.SetConfigFile(path)
    v.SetConfigType("yaml")
    
    if err := v.ReadInConfig(); err != nil {
        return nil, err
    }
    
    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, err
    }
    
    return &cfg, nil
}

🗃️ 第五步:数据库连接模块

5.1 创建数据库连接

创建 internal/db/db.go

package db

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

// NewMySql 创建 MySQL 数据库连接
func NewMySql(dsn string) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }
    
    // 测试连接
    if err := db.Ping(); err != nil {
        return nil, err
    }
    
    return db, nil
}

📊 第六步:数据模型定义

6.1 用户模型

创建 internal/model/user.go

package model

import "time"

// User 用户结构体
type User struct {
    ID        int64     `json:"id"`
    Username  string    `json:"username"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

6.2 文章模型

创建 internal/model/article.go

package model

import "time"

// Article 文章结构体
type Article struct {
    ID        int64     `json:"id"`
    UserID    int64     `json:"user_id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

🔧 第七步:Repository 层开发

Repository 层负责数据访问,封装数据库操作,为上层提供统一的数据访问接口。

7.1 用户 Repository

创建 internal/repository/user_repository.go

package repository

import (
    "database/sql"
    "github.com/openapphub/golang-gin-mysql/internal/model"
)

type UserRepository struct {
    db *sql.DB
}

func NewUserRepo(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

// Create 创建用户
func (r *UserRepository) Create(user *model.User) error {
    query := `INSERT INTO users (username, email) VALUES (?, ?)`
    result, err := r.db.Exec(query, user.Username, user.Email)
    if err != nil {
        return err
    }
    
    id, err := result.LastInsertId()
    if err != nil {
        return err
    }
    
    user.ID = id
    return nil
}

// GetByID 根据ID获取用户
func (r *UserRepository) GetByID(id int64) (*model.User, error) {
    query := `SELECT id, username, email, created_at, updated_at FROM users WHERE id = ?`
    row := r.db.QueryRow(query, id)
    
    user := &model.User{}
    err := row.Scan(&user.ID, &user.Username, &user.Email, &user.CreatedAt, &user.UpdatedAt)
    if err != nil {
        return nil, err
    }
    
    return user, nil
}

// Update 更新用户
func (r *UserRepository) Update(user *model.User) error {
    query := `UPDATE users SET username = ?, email = ? WHERE id = ?`
    _, err := r.db.Exec(query, user.Username, user.Email, user.ID)
    return err
}

// Delete 删除用户
func (r *UserRepository) Delete(id int64) error {
    query := `DELETE FROM users WHERE id = ?`
    _, err := r.db.Exec(query, id)
    return err
}

7.2 文章 Repository

创建 internal/repository/article_repository.go

package repository

import (
    "database/sql"
    "github.com/openapphub/golang-gin-mysql/internal/model"
)

type ArticleRepository struct {
    db *sql.DB
}

func NewArticleRepo(db *sql.DB) *ArticleRepository {
    return &ArticleRepository{db: db}
}

// Create 创建文章
func (r *ArticleRepository) Create(article *model.Article) error {
    query := `INSERT INTO articles (user_id, title, content) VALUES (?, ?, ?)`
    result, err := r.db.Exec(query, article.UserID, article.Title, article.Content)
    if err != nil {
        return err
    }
    
    id, err := result.LastInsertId()
    if err != nil {
        return err
    }
    
    article.ID = id
    return nil
}

// GetByID 根据ID获取文章
func (r *ArticleRepository) GetByID(id int64) (*model.Article, error) {
    query := `SELECT id, user_id, title, content, created_at, updated_at FROM articles WHERE id = ?`
    row := r.db.QueryRow(query, id)
    
    article := &model.Article{}
    err := row.Scan(&article.ID, &article.UserID, &article.Title, &article.Content, &article.CreatedAt, &article.UpdatedAt)
    if err != nil {
        return nil, err
    }
    
    return article, nil
}

// GetByUserID 获取用户的所有文章
func (r *ArticleRepository) GetByUserID(userID int64) ([]*model.Article, error) {
    query := `SELECT id, user_id, title, content, created_at, updated_at FROM articles WHERE user_id = ?`
    rows, err := r.db.Query(query, userID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var articles []*model.Article
    for rows.Next() {
        article := &model.Article{}
        err := rows.Scan(&article.ID, &article.UserID, &article.Title, &article.Content, &article.CreatedAt, &article.UpdatedAt)
        if err != nil {
            return nil, err
        }
        articles = append(articles, article)
    }
    
    return articles, nil
}

// Update 更新文章
func (r *ArticleRepository) Update(article *model.Article) error {
    query := `UPDATE articles SET title = ?, content = ? WHERE id = ?`
    _, err := r.db.Exec(query, article.Title, article.Content, article.ID)
    return err
}

// Delete 删除文章
func (r *ArticleRepository) Delete(id int64) error {
    query := `DELETE FROM articles WHERE id = ?`
    _, err := r.db.Exec(query, id)
    return err
}

7.3 Repository 层设计要点

  1. 单一职责:每个 Repository 只负责一个实体的数据访问
  2. 接口隔离:提供最小必要的接口
  3. 错误处理:统一处理数据库操作错误
  4. 资源管理:正确关闭数据库连接和结果集

🏢 第八步:Service 层开发

Service 层处理业务逻辑,协调多个 Repository 操作,管理事务。

8.1 用户服务

创建 internal/service/user_service.go

package service

import (
    "database/sql"
    "github.com/openapphub/golang-gin-mysql/internal/model"
    "github.com/openapphub/golang-gin-mysql/internal/repository"
)

type UserService struct {
    db           *sql.DB
    userRepo     *repository.UserRepository
    articleRepo  *repository.ArticleRepository
}

func NewUserService(db *sql.DB, userRepo *repository.UserRepository, articleRepo *repository.ArticleRepository) *UserService {
    return &UserService{
        db:          db,
        userRepo:    userRepo,
        articleRepo: articleRepo,
    }
}

// CreateUser 创建用户(包含默认文章)
func (s *UserService) CreateUser(user *model.User) error {
    // 开始事务
    tx, err := s.db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()
    
    // 创建用户
    if err := s.userRepo.Create(user); err != nil {
        return err
    }
    
    // 创建默认文章
    defaultArticle := &model.Article{
        UserID:  user.ID,
        Title:   "欢迎使用系统",
        Content: "这是您的第一篇文章,欢迎使用我们的系统!",
    }
    
    if err := s.articleRepo.Create(defaultArticle); err != nil {
        return err
    }
    
    // 提交事务
    return tx.Commit()
}

// GetUser 获取用户信息
func (s *UserService) GetUser(id int64) (*model.User, error) {
    return s.userRepo.GetByID(id)
}

// UpdateUser 更新用户信息
func (s *UserService) UpdateUser(user *model.User) error {
    return s.userRepo.Update(user)
}

// DeleteUser 删除用户
func (s *UserService) DeleteUser(id int64) error {
    return s.userRepo.Delete(id)
}

// GetUserWithArticles 获取用户及其文章列表
func (s *UserService) GetUserWithArticles(id int64) (*model.User, []*model.Article, error) {
    user, err := s.userRepo.GetByID(id)
    if err != nil {
        return nil, nil, err
    }
    
    articles, err := s.articleRepo.GetByUserID(id)
    if err != nil {
        return nil, nil, err
    }
    
    return user, articles, nil
}

8.2 Service 层设计要点

  1. 业务逻辑封装:将复杂的业务规则封装在 Service 层
  2. 事务管理:确保数据一致性
  3. 依赖注入:通过构造函数注入依赖
  4. 错误处理:统一处理业务逻辑错误

🌐 第九步:Handler 层开发

Handler 层处理 HTTP 请求和响应,参数验证,调用 Service 层。

9.1 用户处理器

创建 internal/handler/user_handler.go

package handler

import (
    "net/http"
    "strconv"
    
    "github.com/gin-gonic/gin"
    "github.com/openapphub/golang-gin-mysql/internal/model"
    "github.com/openapphub/golang-gin-mysql/internal/service"
)

type UserHandler struct {
    userService *service.UserService
}

func NewUserHandler(userService *service.UserService) *UserHandler {
    return &UserHandler{userService: userService}
}

// Create 创建用户
func (h *UserHandler) Create(c *gin.Context) {
    var user model.User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    if err := h.userService.CreateUser(&user); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusCreated, user)
}

// Get 获取用户信息
func (h *UserHandler) Get(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
        return
    }
    
    user, articles, err := h.userService.GetUserWithArticles(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    
    response := gin.H{
        "user":     user,
        "articles": articles,
    }
    
    c.JSON(http.StatusOK, response)
}

// Update 更新用户信息
func (h *UserHandler) Update(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
        return
    }
    
    var user model.User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    user.ID = id
    if err := h.userService.UpdateUser(&user); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, user)
}

// Delete 删除用户
func (h *UserHandler) Delete(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
        return
    }
    
    if err := h.userService.DeleteUser(id); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
}

9.2 文章处理器

创建 internal/handler/article_handler.go

package handler

import (
    "net/http"
    "strconv"
    
    "github.com/gin-gonic/gin"
    "github.com/openapphub/golang-gin-mysql/internal/model"
    "github.com/openapphub/golang-gin-mysql/internal/repository"
)

type ArticleHandler struct {
    articleRepo *repository.ArticleRepository
}

func NewArticleHandler(articleRepo *repository.ArticleRepository) *ArticleHandler {
    return &ArticleHandler{articleRepo: articleRepo}
}

// Create 创建文章
func (h *ArticleHandler) Create(c *gin.Context) {
    var article model.Article
    if err := c.ShouldBindJSON(&article); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    if err := h.articleRepo.Create(&article); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusCreated, article)
}

// Get 获取单篇文章
func (h *ArticleHandler) Get(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid article ID"})
        return
    }
    
    article, err := h.articleRepo.GetByID(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
        return
    }
    
    c.JSON(http.StatusOK, article)
}

// Update 更新文章
func (h *ArticleHandler) Update(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid article ID"})
        return
    }
    
    var article model.Article
    if err := c.ShouldBindJSON(&article); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    article.ID = id
    if err := h.articleRepo.Update(&article); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, article)
}

// Delete 删除文章
func (h *ArticleHandler) Delete(c *gin.Context) {
    id, err := strconv.ParseInt(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid article ID"})
        return
    }
    
    if err := h.articleRepo.Delete(id); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, gin.H{"message": "Article deleted successfully"})
}

// ListByUser 获取用户的所有文章
func (h *ArticleHandler) ListByUser(c *gin.Context) {
    userID, err := strconv.ParseInt(c.Param("uid"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
        return
    }
    
    articles, err := h.articleRepo.GetByUserID(userID)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(http.StatusOK, articles)
}

9.3 Handler 层设计要点

  1. 参数验证:验证请求参数的有效性
  2. 错误处理:统一的错误响应格式
  3. 状态码:使用正确的 HTTP 状态码
  4. JSON 序列化:自动处理 JSON 序列化

🚀 第十步:主程序入口

10.1 创建主程序

创建 cmd/server/main.go

package main

import (
    "log"
    
    "github.com/gin-gonic/gin"
    "github.com/openapphub/golang-gin-mysql/internal/config"
    "github.com/openapphub/golang-gin-mysql/internal/db"
    "github.com/openapphub/golang-gin-mysql/internal/handler"
    "github.com/openapphub/golang-gin-mysql/internal/repository"
    "github.com/openapphub/golang-gin-mysql/internal/service"
)

func main() {
    // 1. 加载配置
    cfg, err := config.Load("config.yaml")
    if err != nil {
        log.Fatalf("[CONFIG] load failed: %v", err)
    }
    
    // 2. 初始化数据库连接
    sqlDB, err := db.NewMySql(cfg.MySQL.DSN)
    if err != nil {
        log.Fatalf("[DB] connect failed: %v", err)
    }
    defer sqlDB.Close()
    
    // 3. 创建 Repository 层实例
    userRepo := repository.NewUserRepo(sqlDB)
    articleRepo := repository.NewArticleRepo(sqlDB)
    
    // 4. 创建 Service 层实例
    userSvc := service.NewUserService(sqlDB, userRepo, articleRepo)
    
    // 5. 初始化 Gin 引擎
    r := gin.Default()
    
    // 6. 注册路由
    userHandler := handler.NewUserHandler(userSvc)
    userGroup := r.Group("/users")
    {
        userGroup.POST("", userHandler.Create)
        userGroup.GET("/:id", userHandler.Get)
        userGroup.PUT("/:id", userHandler.Update)
        userGroup.DELETE("/:id", userHandler.Delete)
    }
    
    articleHandler := handler.NewArticleHandler(articleRepo)
    articleGroup := r.Group("/articles")
    {
        articleGroup.POST("", articleHandler.Create)
        articleGroup.GET("/:id", articleHandler.Get)
        articleGroup.PUT("/:id", articleHandler.Update)
        articleGroup.DELETE("/:id", articleHandler.Delete)
        articleGroup.GET("/user/:uid", articleHandler.ListByUser)
    }
    
    // 7. 启动服务器
    log.Printf("[SERVER] listening on %s", cfg.Server.Port)
    if err := r.Run(cfg.Server.Port); err != nil {
        log.Fatalf("[SERVER] run failed: %v", err)
    }
}

10.2 主程序设计要点

  1. 依赖注入:按层次注入依赖
  2. 错误处理:启动失败时记录日志并退出
  3. 资源管理:正确关闭数据库连接
  4. 路由组织:使用路由组组织相关接口

🧪 第十一步:测试和调试

11.1 启动服务器

go run cmd/server/main.go

看到以下输出表示启动成功:

[CONFIG] load success
[DB] connect success
[SERVER] listening on :8080

11.2 测试 API 接口

创建用户
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"username": "john_doe", "email": "john@example.com"}'
获取用户信息
curl http://localhost:8080/users/1
创建文章
curl -X POST http://localhost:8080/articles \
  -H "Content-Type: application/json" \
  -d '{"user_id": 1, "title": "我的第一篇文章", "content": "这是文章内容..."}'
获取用户的所有文章
curl http://localhost:8080/articles/user/1

11.3 常见问题排查

  1. 数据库连接失败

    • 检查 MySQL 服务是否启动
    • 验证配置文件中的数据库连接信息
    • 确认数据库和表是否已创建
  2. 端口被占用

    • 修改 config.yaml 中的端口配置
    • 使用 lsof -i :8080 查看端口占用情况
  3. 依赖安装失败

    • 检查网络连接
    • 确认 Go 版本是否符合要求
    • 使用 go mod tidy 重新整理依赖

📚 第十二步:学习总结

12.1 项目架构回顾

通过这个项目,我们学习了:

  1. 分层架构设计:Handler → Service → Repository → Model
  2. 依赖注入:通过构造函数注入依赖
  3. 错误处理:Go 语言的错误处理机制
  4. 数据库操作:使用 database/sql 包
  5. RESTful API:标准的 REST 接口设计
  6. 配置管理:使用 Viper 管理配置

12.2 扩展建议

  1. 添加中间件:日志、认证、限流等
  2. 数据验证:使用 validator 库
  3. 单元测试:为每个层添加测试
  4. API 文档:使用 Swagger 生成文档
  5. 容器化:使用 Docker 部署

12.3 最佳实践

  1. 代码组织:按功能模块组织代码
  2. 错误处理:统一的错误处理机制
  3. 日志记录:适当的日志记录
  4. 配置管理:外部化配置
  5. 数据库设计:合理的数据表设计

🎉 恭喜!

你已经成功构建了一个完整的 Go Web API 项目!这个项目展示了 Go 语言 Web 开发的最佳实践,包括:

  • 清晰的项目结构
  • 分层架构设计
  • 数据库操作
  • RESTful API 设计
  • 错误处理

继续学习更多 Go 语言特性和 Web 开发技术,构建更复杂的应用程序!


Happy Coding! 🚀

GitHub源码

运行:

image.png