Golang 开发人员的 Web 服务架构

2025-06-08

Golang 开发人员的 Web 服务架构

本文最初发表于boobo94.xyz。如果您想阅读更多类似的文章,请订阅我的新闻通讯或关注我。

Web 服务架构是构建每个项目之前的第一个阶段,就像您准备建造房屋并从创建架构计划开始一样。

本文将介绍我在 Golang 中创建一个简单的 Web 服务时如何构建项目。保持简单但直观的架构非常重要,因为众所周知,在 Golang 中,你可以通过引用包名来调用方法。

在下文中,我将介绍一个简单但传统的 Web 服务架构模型,该模型是我参与的大多数项目中使用的模型,用于处理每个单独的 Web 服务组件。

/api

API 包是一个文件夹,所有 API 端点都根据其用途分组到不同的子包中。这意味着,我更喜欢使用一个专门的包,其主要作用是解决特定的问题。

例如所有的登录、注册、忘记密码、重置密码处理程序,我更喜欢定义到名为registry的包中。

注册包如下所示:

├── api
│   ├── auth
│   │   ├── principal.middleware.go
│   │   └── jwt.helper.go
│   ├── cmd
│   │   └── main.go
│   ├── registration
│   │   ├── login.handler.go
│   │   ├── social_login.handler.go
│   │   ├── register.handler.go
│   │   ├── social_register.handler.go
│   │   ├── reset.handler.go
│   │   ├── helper.go
│   │   └── adapter.go
├── cmd
│   └── main.go
├── config
│   ├── config.dev.json
│   ├── config.local.json
│   ├── config.prod.json
│   ├── config.test.json
│   └── config.go
├── db
│   ├── handlers
│   ├── models
│   ├── tests
│   ├── db.go
│   └── service.go
├── locales
│   ├── en.json
│   └── fr.json
├── public
├── vendor
├── Makefile
..........................

Enter fullscreen mode Exit fullscreen mode

处理程序.go

如你所见,文件名中有一个handler.go后缀。你可以在这些文件中有效地编写处理请求的代码,这些代码将从数据库中检索请求的数据,进行处理,并最终生成响应。

下面是一个可以更好地解释的简单示例:

http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
    ...
    // handle the request
})

Enter fullscreen mode Exit fullscreen mode

助手.go

有时,在发送响应之前,您需要从多个地方收集数据进行处理,然后,当所有详细信息收集完毕后,就可以将响应发送到客户端应用。但是,处理程序中的代码必须尽可能简洁,以便所有额外的代码(这些代码属于流程的一部分)都可以放在这里。

适配器.go

在客户端与 Web 服务之间的交互中,它们会发送和接收数据,但与此同时,可能还会涉及第三方API、其他应用程序或数据库。考虑到这一点,在将数据从一个应用程序传输到另一个应用程序之前,我们需要转换格式,以便新应用程序能够接收数据。转换函数可以写在这个adapter.go文件中。

例如,如果我需要将一个 struct 转换A为一个 struct B,我需要一个如下所示的适配器函数:

type A struct {
    FirstName string
    LastName  string
    Email     string
}

type B struct {
    Name  string
    Email string
}

func ConvertAToB(obj A) B {
    return B{
        Name:  obj.FirstName + obj.LastName,
        Email: obj.Email,
    }
}

Enter fullscreen mode Exit fullscreen mode

/api/授权

大多数 Web 服务必须至少实现一种授权方法,例如:

  • OAuth — 开放身份验证
  • 基本身份验证
  • 令牌认证(我更喜欢使用 JWT — JSON Web Token
  • OpenID

我个人使用JWT,因为我为客户(ATNM)编写 Web 服务,主要用于移动应用或CMS。如果您想了解有关Web 身份验证 API 的更多信息,Mozilla 有一篇很棒的文章对其进行了很好的解释。

jwt

什么是 JWT?

JSON Web Tokens 是一种开放的、行业标准RFC 7519方法,用于在双方之间安全地表示声明。

为什么要使用 JWT?
  • 授权:这是使用 JWT 最常见的场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是如今 JWT 广泛使用的一项功能,因为它开销小,并且易于跨域使用。
  • 信息交换:JSON Web Token 是各方之间安全传输信息的有效方式。由于 JWT 可以签名(例如,使用公钥/私钥对),因此您可以确保发送者的身份与其声明相符。此外,由于签名是使用标头和有效负载计算得出的,因此您还可以验证内容未被篡改。

因此,您必须验证签名、对主体进行编码或解码,或者编写 JWT 主体。为了处理此类流程,我创建了jwt.helper.go文件,以便保持一致性,并让所有与 JWT 相关的代码都能在auth包下找到

我们来讨论一下auth包中的另一个文件principal.middleware.go。该文件之所以如此命名,是因为它是与任何 API 交互的第一个中间件,所有请求都会经过它。在这个文件中,我编写了一个函数,用于阻止所有请求,如果未通过规则,则会发送401 状态码作为响应。现在,如果您想知道这些规则是什么,我们已经讨论过 JWT,因此客户端必须附加到任何请求(除了登录、注册等不需要授权的端点之外),并发送一个HTTP 标头Authorization 其中必须包含 JWT 令牌。

总而言之,如果客户端应用程序未发送令牌,或者令牌已损坏或无效,则 Web 服务将使请求无效。

从哪里获取该令牌?

这可能是您在阅读上一段时想到的一个问题,所以让我们来解释一下。我提到过,在登录或注册时(是的,其他路由可能也不需要身份验证),您不需要发送令牌,因为您实际上会从这些请求中获取令牌。因此,您填写您的凭证,如果凭证正确,您将在登录时在响应中收到一个令牌,该令牌将在每个需要该凭证的请求中发送。

/命令

我总是喜欢把main.go文件放在这个包里,它包含了项目的所有子包。它就像一个包装器,封装了所有子模块,使它们协同工作。

为什么名称是这样的?很简单,因为cmd是 command 的缩写。

如何理解“命令”?命令表示一项任务,它可以作为某项任务的一部分,调用其他任务,也可以独立运行。main.go文件通常将 Web 服务的所有函数和包封装在一个文件中,并仅调用任何包的主要函数。任何时候,如果您想删除某个功能,只需在主文件中注释掉该实例即可。

/配置

在我看来,这个包非常重要,因为它将所有配置保存在一个地方,无需在项目的所有文件中四处搜索,非常实用。在这个包中,我通常会编写一个名为config.go的文件,其中包含配置的模型。这个模型其实就是一个结构体,例如:

type JWT struct {
    Secret string `required:"true"`
}

type Database struct {
    Dialect  string `default:"postgres"`
    Debug    bool   `default:"false"`
    Username string `required:"true"`
    Password string `required:"true"`
    Host     string `required:"true"`
    Port     int
    SSLMode bool
}

type Configuration struct {
    Database Database `required:"true"`
    JWT      JWT      `required:"true"`
}

Enter fullscreen mode Exit fullscreen mode

但这仅仅是结构体定义,我们仍然需要将真正的数据存放在某个地方。对于这部分,我更喜欢使用多个JSON 文件,根据环境进行设置,并将它们命名为config.ENV.json。对于之前定义的结构体,虚拟 JSON 示例如下:

 {
    "Database": {
        "Dialect": "postgres",
        "Debug": true,
        "Username": "postgres",
        "Password": "pass",
        "Host": "example.com",
        "Port": 5432,
        "SSLMode": true,
    },
    "JWT": {
        "Secret": "abcdefghijklmnopqrstuvwxyz"
    }
}
Enter fullscreen mode Exit fullscreen mode

我们来谈谈业务方面的问题吧,因为这部分对我来说非常特殊,而且对于我投入时间寻找最佳解决方案来说也非常重要。我不知道您是否遇到过这个问题,或者对您来说,这可能不算什么问题,但我在尝试以正确的方式导入配置时确实遇到了一些问题。有很多可能性,但我不得不在两者之间做出选择:

  • 将配置对象作为变量从 main.go 传递到最终需要使用它的函数。这当然是个好主意,因为我只在需要它的实例中传递该变量,这样就不会影响速度和质量。但这对于开发或重构来说非常耗时,因为我需要一直将配置从一个函数传递到另一个函数,所以到最后,你会想自杀,嗯……也许不是,但我还是不喜欢这样。
  • 声明一个全局变量,并在任何需要的地方使用该实例。但在我看来,这根本不是最佳选择,因为我必须声明一个变量,例如在 main.go 文件中,然后在主函数中Unmarshal(),我需要将 JSON 文件的内容放入声明为全局变量的对象中。但你猜怎么着,也许我在对象初始化完成之前就尝试调用它,这样我得到的就是一个空对象,没有任何实际值,在这种情况下我的应用程序就会崩溃。
  • 将配置对象直接注入到我需要的地方,是的,这是最适合我的最佳选择。在config.go文件的末尾,我声明了以下几行:
var Main = (func() Configuration {

    var conf Configuration
    if err := configor.Load(&conf, "PATH_TO_CONFIG_FILE"); err != nil {
        panic(err.Error())
    }
    return conf
})()

Enter fullscreen mode Exit fullscreen mode

对于此实现您需要知道的是,我使用了一个名为Configor的库,它可以解组一个文件(在我们的例子中是 JSON),并将其加载到一个变量中conf,然后返回。

任何时候当您需要使用配置中的某些内容时,只需键入包名称(即)config,并调用变量即可Main,如以下检索数据库配置的示例:

var myDBConf = config.Main.Database

Enter fullscreen mode Exit fullscreen mode

!!!提示:如你所见,你必须在此处插入配置文件的路径,但由于你希望在不同环境下使用不同的文件,因此你可以设置一个名为 的环境变量CONFIG_PATH。将其定义为 env 变量,或者在运行 go 之前将其输入,如下所示:

$ CONFIG_PATH=home/username/.../config.local.json go run cmd/main.go

Enter fullscreen mode Exit fullscreen mode

而不是PATH_TO_CONFIG_FILEos.Getenv("CONFIG_PATH")。这样,它就不会知道你的文件路径在哪里……所以你可以跳过一些操作系统错误。

/db

这个db包是你的 Web 服务中最重要的包之一,你必须投入大量时间思考它的架构和开发,因为它是 Web 服务的目的之一,即收集和存储数据。接下来我将介绍我自己的版本,它完美适用于我构建 Web 服务的大多数情况,敬请期待。

在深入探讨文件夹结构之前,我先坦白两点:我更喜欢使用ORM,因为它操作起来更简单,而且提供了一种更好的对象处理方法,而不是像 SQL 查询那样,需要将数据转换成数组,然后尝试调试一个简单的查询。我使用GORM是因为它满足了我所有的要求:拥有所有基本的 ORM 函数(查找、更新、删除等)、支持关联(包含一个、包含多个、属于、多对多、多态)、支持事务、拥有 SQL 生成器、自动迁移以及其他一些很酷的功能。

/db.go

我使用这个文件来保存 GORM 的所有重要配置。因此,我在这个文件中创建了一个函数,该函数将以对象的形式返回数据库连接。该函数将在main.go中被调用,并传递给所有需要与数据库交互的 API。

// NewDatabase returns a new Database Client Connection
func NewDatabase(config *config.Database) (*gorm.DB, error) {
    db, err := gorm.Open(config.Dialect, config.Source)
    if err != nil {
        return nil, err
    }

    if err := InitDatabase(db, config); err != nil {
        return nil, err
    }

    return db, nil
}

Enter fullscreen mode Exit fullscreen mode

正如您在 NewDatabase 中看到的,在第 8 行,它被称为一个函数InitDatabase(),该函数定义了我的 ORM 的行为并执行 AutoMigration

// InitDatabase initializes the database
func InitDatabase(db *gorm.DB, config *config.Database) error {
    db.LogMode(config.Debug)

    // auto migrate
    models := []interface{}{
        &models.Account{},
        &models.PersonalInfo{},
        &models.Category{},
        &models.Subcategory{},
    }
    if err := db.AutoMigrate(models...).Error; err != nil {
        return err
    }

    // Personal info
    if err := db.Model(&models.PersonalInfo{}).AddForeignKey("account_id", fmt.Sprintf("%s(id)", models.AccountTableName), "CASCADE", "CASCADE").Error; err != nil {
        return err
    }

    // Subcategories
    if err := db.Model(&models.Subcategory{}).AddForeignKey("category_id", fmt.Sprintf("%s(id)", models.CategoryTableName), "CASCADE", "CASCADE").Error; err != nil {
        return err
    }

    return nil
}

Enter fullscreen mode Exit fullscreen mode

自动迁移将验证表是否存在,如果不存在或模型不同,则将尝试进行同步。

除了自动迁移之外,我还手动设置外键,或者如果需要,设置索引或其他 SQL 约束。

main.go 中实例化的一个简单示例如下:

// setup the database
    dbc, err := db.NewDatabase(&config.Config.Database)
    if err != nil {
        panic(err.Error())
    }

Enter fullscreen mode Exit fullscreen mode

/service.go

此文件的目的是为所有处理程序维护一个结构,避免在多个位置导入同一个处理程序,或者为了避免不一致,只需从main.go向所有 API 处理程序传递一个包含对所有数据库处理程序引用的对象即可。因此,此文件如下所示:

package db

import (
    "github.com/jinzhu/gorm"
    "PROJECT_FOLDER/db/handlers"
)

type Service struct {
    Account  *handlers.AccountHandler
    Category *handlers.CategoryHandler
}

func NewService(db *gorm.DB) Service {
    return Service{
        Account:  handlers.NewAccountHandler(db),
        Category: handlers.NewCategoryHandler(db),
    }
}

Enter fullscreen mode Exit fullscreen mode

正如你所见,我只有两个处理程序,它们不包含 PersonalInfo 和 Subcategory,因为它们不是必需的,它们属于主处理程序的一部分。例如,如果你不知道分配给它的账户,你不需要个人信息,所以它们将被包装在一个对象中。

可以在main.go中简单调用,如下所示:

// create the database service
    dbService := db.NewService(dbc)

Enter fullscreen mode Exit fullscreen mode

/db/模型

模型通常只是普通的 Golang 结构、基本 Go 类型或它们的指针。

如你所见,我在自动迁移功能中放入了四个模型:Account、PersonalInfo、Category 和 Subcategory。我喜欢将每个模型定义到不同的文件中,并选择一个直观的名称,例如account.gopersonalInfo.gocategory.gosubcategory.go

举个例子,account.go 的内容如下:

package models

import (
    "crypto/md5"
    "encoding/hex"
    "PROJECT_FOLDER/utils"

    "github.com/jinzhu/gorm"
    "golang.org/x/crypto/bcrypt"
)

type Role string

const (
    RoleAdmin Role = "admin"
    RoleUser  Role = "user"
)

const (
    AccountTableName = "accounts"
)

type Account struct {
    gorm.Model

    Username string `gorm:"type:varchar(100); unique; not null"`
    Password string `gorm:"not null"`
    Role     Role   `gorm:"type:varchar(5); not null"`
    Active   bool   `gorm:"not null"`
    Token    string `gorm:"not null"`

    IsSocial     bool `gorm:"not null"`
    Provider     string
    PersonalInfo PersonalInfo
}

func (account *Account) BeforeCreate() error {
    password, err := HashPassword(account.Password)
    if err != nil {
        return err
    }
    account.Password = *password
    account.Token = GenerateToken()

    return nil
}

func HashPassword(password string) (*string, error) {
    hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }

    pass := string(hash)
    return &pass, nil
}

func GenerateToken() string {
    hasher := md5.New()

    // you can check the utils.RandStr() in /utils chapter of this article
    hasher.Write([]byte(utils.RandStr(32)))

    return hex.EncodeToString(hasher.Sum(nil))
}

Enter fullscreen mode Exit fullscreen mode

/db/处理程序

数据库处理程序代表在多个地方重复的样板代码,因此,与其调用 GORM 函数,不如调用准备在 API 处理程序内部使用的响应的函数。

package handlers

import (
    "PROJECT_FOLDER/db/models"

    "fmt"

    "github.com/jinzhu/gorm"
)

type AccountHandler struct {
    db *gorm.DB
}

func NewAccountHandler(db *gorm.DB) *AccountHandler {
    return &AccountHandler{
        db,
    }
}

func (h *AccountHandler) Find(accountID uint) (*models.Account, error) {
    var res models.Account

    if err := h.db.Find(&res, "id = ?", accountID).Error; err != nil {
        return nil, err
    }

    return &res, nil
}

func (h *AccountHandler) FindBy(cond *models.Account) (*models.Account, error) {
    var res models.Account

    if err := h.db.Find(&res, cond).Error; err != nil {
        return nil, err
    }

    return &res, nil
}

func (h *AccountHandler) Update(account *models.Account, accountID uint) error {
    return h.db.Model(models.Account{}).Where(" id = ? ", accountID).Update(account).Error
}

func (h *AccountHandler) Create(account *models.Account) error {
    return h.db.Create(account).Error
}

func (h *AccountHandler) UpdateProfile(profile *models.PersonalInfo, accountID uint) error {
    var personalInfo models.PersonalInfo
    cond := &models.PersonalInfo{
        AccountID: accountID,
    }

    // only create it if it doesn't already exist
    if h.db.First(&personalInfo, cond).RecordNotFound() {
        profile.AccountID = accountID
        return h.db.Create(profile).Error
    }

    return h.db.Model(models.PersonalInfo{}).Where(cond).Update(profile).Error
}

Enter fullscreen mode Exit fullscreen mode

如果您想要一个 SQL 包处理程序的示例,我向您推荐XZYASQLHandler

/db/测试

我知道你的项目经理可能跳过了这一步,因为项目必须尽快准备就绪,但相信我,写测试总是更好的选择。所以,在我看来,Go 中一个编写单元测试的好包是Testify,它使用起来非常简单,而且功能非常强大。

/gen

Gen 文件夹是放置所有由第三方库生成的代码的文件夹。将所有代码保存在一个地方非常简单,因为也许……您需要在生成新版本之前不时地清理它,您可以通过简单地使用Makefile任务来做到这一点,我们稍后会讨论这个问题。

在工作中,我们通常使用Swagger,它使我们的工作更加轻松,并帮助我们维护一个文件,该文件可作为API 声明代码生成文档的基础。但是,由于 Swagger 非常酷,而且我们只是普通人,因此使用图形界面比编写YAML或 JSON 规范文件要简单得多。对于这类工作,我们使用StopLight。它的作用基本上是提供一个图形界面,并在工作完成后导出所需的规范文件。

/区域设置

在大多数情况下,翻译是由客户端应用程序实现的,但有时您可能需要发送一些自定义错误或一些翻译的电子邮件模板,这时您就会遇到问题。在我作为 Go 开发人员的职业生涯中,我曾遇到过这种困境,我可以选择构建一些非常简单和非常基础的东西,或者查看现有的包,也许它适合我。是的,我找到了一个非常简单的,但正是我需要的。我想要一个可以读取 JSON 文件的简单包,因为客户端应用程序已经有了这些翻译,这样我就不必创建额外的东西了,所以我发现了GoTrans。这个包的很酷之处在于您可以在cmd/main.go中声明它,然后您可以在项目内的任何位置调用翻译函数。

如何初始化Gotrans?

// initialize locales
    if err := gotrans.InitLocales("locales"); err != nil {
        panic(err)
    }

Enter fullscreen mode Exit fullscreen mode

如何使用 Gotrans?

JSON 文件如下所示:

{
    "hello_world":"Hello World",
    "find_more":"Find more information about the project on the website %s"
}

Enter fullscreen mode Exit fullscreen mode

JSON 文件名应使用浏览器支持的标准语言代码或语言国家代码。本地化文件夹中至少应包含一个“en.json”文件。

 gotrans.Tr("fr", "hello_world")

Enter fullscreen mode Exit fullscreen mode

/民众

你可能会问自己“什么?”!Web 服务里有公共文件夹?!没错,虽然可能并非总是需要,但我尽量解释 Web 服务的通用架构,偶尔你需要一些像“条款和条件”页面、“隐私政策”或 HTML 邮件模板之类的公开内容,并将它们作为资源导出到公共 API 上。

/实用程序

构建大型项目时,有时需要额外的工具或辅助工具来解决一些小问题。但这些辅助工具只是一小段代码,因此无需为了简单的小段代码而单独创建一个包。所以,utils包可以帮你解决这个问题,因为你可以将不同的代码放入单独的文件中,例如:

  • 生成随机令牌
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func RandStr(n int) string {
    b := make([]rune, n)
    for i := range b {
        b[i] = letters[rand.Intn(len(letters))]
    }
    return string(b)
}

Enter fullscreen mode Exit fullscreen mode
  • 生成哈希密码
  • 创建处理程序以在云端上传
  • 创建处理程序来发送电子邮件
  • 日志管理器
  • ETC..

基本上,这里是存储所有无法分类的混乱的地方,但它不仅仅是混乱,因为这种混乱使您的生活更轻松,并且可能帮助您节省时间,因为不必在许多地方重复编写相同的代码。

/小贩

此文件夹是唯一一个您无需更改任何内容的地方,所有导入项目的外部依赖项或软件包都会下载并存储在这里,以便您构建项目。此build文件夹会在run任务中自动创建,因为在编译项目之前,它会验证所有导入的依赖项是否位于 vendor 文件夹中。

如何下载包?

这里有多个正确答案,我不想参与争论,但我可以告诉你,默认答案是go get PACKAGE依赖项放入$GOPATH/src,或者go install PACKAGE将二进制文件放入$GOPATH/bin,将包放入$GOPATH/pkg

如何管理包裹?

你现在可能会问:“好吧,但是我该如何把所有依赖项放在一起,并且用一个简单的命令安装它们,而不是运行多个命令,比如我需要更改环境?”答案很简单,使用管理依赖项的工具。使用依赖项工具,你可以完成一些基本任务,并节省一些时间。

我更喜欢Golang 的默认DEP 。

dep管理依赖工具

可以使用 brew for MAC 轻松安装

 $ brew install dep
 $ brew upgrade dep

Enter fullscreen mode Exit fullscreen mode

或者使用 CURL

 $ curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh

Enter fullscreen mode Exit fullscreen mode

Makefile

我使用 make 文件,因为它简单易用,可以自动执行一些我需要不时重复的任务。而且,由于我在创建构建(例如,构建)之前必须执行一些步骤,而我需要在几个月甚至几年后再次执行此过程,因此我可能需要花一些时间才能弄清楚如何进行构建。与其花费所有时间重新探索如何构建我的项目,不如等到这些信息变得有用时再进行构建,之后我只需查看 make 文件并选择需要运行的任务即可。
我想与大家分享一些我在大多数项目中使用的简单基本任务:

IP = "XXX.XXX.XXX.XXX"
PEM_FILE = "...PATH_TO_PEM/key.pem"

# Run the server
run:
    CONFIG=config/config.local.json go run cmd/main.go --port 8000

# Remove generated files under gen/ folder
clean:
    rm -r gen

serve:
    realize start

# Build
build:
    go build cmd/main.go

# Build for linux
build-linux:
    env GOOS=linux go build cmd/main.go

# Deploy just the code to dev
deploy-dev-code: build-linux copy-project

# Full deploy to dev
# With tests and swagger generation
deploy-dev: gen deploy-dev-code

# Copy the project build and dependencies to server
copy-project:
    ssh -i $(PEM_FILE) ubuntu@$(IP) 'sudo service api stop'
    scp -i $(PEM_FILE) -r locales/ ubuntu@$(IP):/home/ubuntu/project_name
    scp -i $(PEM_FILE) main ubuntu@$(IP):/home/ubuntu/project_name
    ssh -i $(PEM_FILE) ubuntu@$(IP) 'sudo service api start'
    rm main

Enter fullscreen mode Exit fullscreen mode

您可以从GNU.org找到一篇有关makefile及其使用方法的精彩文章

概括

在本文中,您将了解 API 以及如何构建架构、如何从 Web 服务与数据库交互、如何创建配置文件、使用 JWT 处理客户端和服务器之间的安全性和权限以及如何使用其他包让您的生活更轻松,最后您学习了如何使用 make 文件运行多个任务。

如果您有任何疑问或者您认为我遗漏了一些信息,请给我留言。

本文最初发表于boobo94.xyz。如果您想阅读更多类似的文章,请订阅我的新闻通讯或关注我。

鏂囩珷鏉ユ簮锛�https://dev.to/boobo94/web-service-architecture-for-golang-developers-58h3
PREV
Implementando Clean Architecture com Golang
NEXT
为什么我在我的网站上添加终端(以及您也可以这样做)?