Simple Lists: a tiny to-do list app written the old-school way (server-side Go, no JS)

Ben Hoyt

Simple Lists:一款用老派方式编写的极简待办清单应用(服务端 Go,无 JS)

原文由 Ben Hoyt 发布,订阅该博客

摘要:本文将介绍我为何以及如何用 Go 编写了 Simple Lists——一款极简的待办清单网页应用。它采用老派的方式构建:由服务端渲染 HTML,通过 HTML 表单以普通的 GET 和 POST 方式交互,不使用 JavaScript。

我一直想再做个小小的业余项目:动手创造、写点代码,纯粹享受制作的乐趣。同时也想做个对自己或家人有点用的东西,于是就做了这个小小的待办清单应用。我知道市面上已经有成千上万的待办应用,但我就是喜欢亲手打造东西,尤其喜欢符合小网络理念的产品。

如果你已经安装了 Go,可以非常轻松地在本地运行这个应用(首次运行时下载和构建需要几秒钟):

$ git clone https://github.com/benhoyt/simplelists.git
Cloning into 'simplelists'...
...
$ cd simplelists
$ go run .
2021/09/28 20:51:14 listening on http://localhost:8080

可以查看源代码,我们会在下文进一步介绍。话不多说,直接开始吧!

功能

Simple Lists 截图

Simple Lists 的功能列表并不长:

  • 可以创建新清单。每个清单在 URL 中都有一个随机 ID,因此是半私有的:只有知道链接的人才能访问。
  • 可以向清单中添加条目。
  • 可以划掉已完成的条目(也可以撤销划掉)。
  • 可以从清单中删除条目。
  • 在手机上也能正常使用。
  • 可防范跨站脚本(XSS)和跨站请求伪造(CSRF)攻击。

可选功能(我在个人部署的实例中使用了这些):

  • 如果以将 SIMPLELISTS_LISTS 环境变量设为 1 的方式运行服务,首页会显示所有清单的列表,并允许删除清单。
  • 如果设置了 SIMPLELISTS_USERNAMESIMPLELISTS_PASSHASH 环境变量,则访问网站需要使用用户名和密码登录。(仅供我个人使用,所以只有一个用户。)

为了保持简单,Simple Lists 不提供以下功能:

  • 无法对清单中的条目重新排序。
  • 无法编辑条目,需要先删除再重新添加。
  • 没有花哨的颜色、图片或样式。

就像我说的,它就是 Simple Lists——简单至极。不过对我来说够用了!

不用 JS(不是指 Node JS!)

其实一开始我也加了一点客户端 JavaScript,想让交互更流畅一些。但除了我更喜欢用 Go 而不是 JavaScript 编程之外,我意识到根本不需要客户端脚本——只要把应用做得小而快,多几次页面刷新完全不是问题。

于是我删掉了那点 JavaScript,只用 HTTP 的 POST 方法配合最普通的 HTML 表单来修改待办清单数据库的状态。这样做的好处是,你只需要用 Go,再加上一点 HTML。完全没有一行 JavaScript,也不需要 Babel 或 Webpack 这类复杂的构建工具。

对于那些只用过客户端 JavaScript 来实现交互的朋友,我们来快速看一下这种方式是如何工作的。

对于每个清单条目(位于 <ul> 无序列表中),我们使用了如下 HTML:

<li style="margin: 0.7em 0">
 <form style="display: inline;" action="/update-done"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <input type="hidden" name="done" value="on">
  <button id="done-{{ .ID }}" style="width: 1.7em">&nbsp;</button>
  <label for="done-{{ .ID }}">{{ .Description }}</label>
 </form>
 <form style="display: inline;" action="/delete-item"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <button style="padding: 0 0.5em; border: none; background: none;
                 color: #ccc" title="Delete Item">✕</button>
 </form>
</li>

看起来内容不少,但并不复杂。每个条目都有两个表单——一个用于更新条目的“已完成”标记(将其划掉),另一个用于删除条目。

HTML 表单不一定是带有文本框和文件上传控件的笨重界面,它也可以只是几个隐藏字段加一个按钮。正如 delete-item 表单所示,按钮是可以设置样式的——在这个例子中,它被美化成了一个漂亮的 ✕ 图标。

在第一个表单中,你会注意到按钮上使用了 id 属性。它通过 <label>for 属性与之关联,因此条目的描述文本就成了“划掉”按钮的标签——你可以点击或轻触标签上的任意位置,浏览器就会帮你点击按钮,将条目划掉。不用 JavaScript,浏览器也能做很多事!

选择 Go

Go 标准库设计的周到程度经常让我印象深刻,尤其是与我熟悉的另一门语言 Python 相比。服务端唯一的依赖只有 SQLite 数据库驱动,以及用于几个 bcrypt 密码哈希函数的、准标准库的 golang.org/x/crypto/bcrypt 包。

在我的其他一些项目中,我使用的是流行的 mattn/go-sqlite3 SQLite 驱动,效果很好。不过在这个项目里,我想试试 modernc.org/sqlite 驱动。这是一项相当了不起的工程:它是 SQLite 的纯 Go 移植版(没有 CGo 绑定),通过将 SQLite 的 C 源码转译为 Go 实现。生成的 Go 代码当然不忍直视,但运行良好,而且省去了对 C 编译器的依赖,这在 Windows 上以及交叉编译时很有帮助。

我尽量以符合 Go 习惯的方式来写代码。例如,我在所有地方都使用了错误返回值,即使在很想直接 panic 的地方也是如此(比如数据库代码:这些进程内的 SQLite 查询理应只有在我写错查询时才会失败吧?)。

我仍然不太喜欢 Go 错误处理的冗长,尤其是在 HTTP 请求处理器中,return 还要单独占一行。一行代码,却要配四行错误处理。这倒不会让代码难以理解,只是多了些噪音。例如,对比一下 server.go 中的这段处理器代码

list, err := s.model.GetList(id)
if err != nil {
    s.internalError(w, "fetching list", err)
    return
}
if list == nil {
    http.NotFound(w, r)
    return
}

在 Python 中,数据库错误会直接抛出异常,由 Web 框架捕获(并返回 Internal Server Error),而未找到的检查也会更简洁,大致如下:

lst = self.model.get_list(list_id)
if lst is None:
    raise HTTPStatus(NotFound)

在 Go 中你也可以通过 panic 来实现类似效果,但这已经完全不符合惯用法了。Go 的错误处理本来就不够简洁,越早不再为此纠结越好。

简单的 ServeMux 路由

我曾详细写过在 Go 中不同的 HTTP 路由方案,本来已经打算采用正则表的方式,或者使用 chi 路由器。

不过,由于是从零开始创建一个 Web 应用,我可以完全掌控 URL 结构,于是决定简化。我已经太习惯 REST 风格的 URL,以至于这样做感觉有点怪——但从实用角度看,DELETE /lists/{list-id}/items/{item-id} 比起在请求体中携带清单和条目 ID 的 POST /delete-item 又好在哪里呢?而且如果不用 JavaScript,又何必需要基于 JSON 的 API 呢?

做出这个决定后,我就可以直接使用 Go 内置的、简单的 http.ServeMux 类型来做 URL 路由。通过把问题本身设计掉,我简化了实现并减少了依赖。

ServeMux 唯一有点奇怪的地方是,根路径模式 "/" 和所有以斜杠结尾的模式一样,不仅匹配 / 本身,还会匹配其下的所有路径。因此需要显式地做一次检查。下面是应用完整的路由代码:

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path == "/" { // because "/" pattern matches /*
        s.home(w, r)
    } else {
        http.NotFound(w, r)
    }
})
mux.HandleFunc("/sign-in", csrf(s.signIn))
mux.HandleFunc("/sign-out", s.signedIn(csrf(s.signOut)))
mux.HandleFunc("/lists/", s.signedIn(s.showList))
mux.HandleFunc("/create-list", s.signedIn(csrf(s.createList)))
mux.HandleFunc("/delete-list", s.signedIn(csrf(s.deleteList)))
mux.HandleFunc("/add-item", s.signedIn(csrf(s.addItem)))
mux.HandleFunc("/update-done", s.signedIn(csrf(s.updateDone)))
mux.HandleFunc("/delete-item", s.signedIn(csrf(s.deleteItem)))

signedIn 函数是一个中间件包装器,用于添加用户名/密码认证。默认情况下(演示站点也是如此),用户名认证是关闭的,isSignedIn 始终返回 true:

func (s *Server) signedIn(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !s.isSignedIn(r) {
            location := "/?return-url=" + url.QueryEscape(r.URL.Path)
            http.Redirect(w, r, location, http.StatusFound)
            return
        }
        h(w, r)
    }
}

csrf 函数是一个中间件包装器,用于防范跨站请求伪造。它包装给定的处理器,确保 HTTP 方法为 POST,并且 csrf-token Cookie 中的 CSRF 令牌与 csrf-token 表单字段中的令牌一致。这可以确保表单操作只能从 Simple Lists 自身的页面提交:

func csrf(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "POST" {
            w.Header().Set("Allow", "POST")
            http.Error(w, "405 method not allowed",
                http.StatusMethodNotAllowed)
            return
        }
        token := r.FormValue("csrf-token")
        cookie, err := r.Cookie("csrf-token")
        if err != nil || token != cookie.Value {
            http.Error(w, "invalid CSRF token or cookie",
                http.StatusBadRequest)
            return
        }
        h(w, r)
    }
}

数据库处理

数据库的选择毫无悬念。我一直很推崇 SQLite,对于这种规模的项目,它再合适不过。对于更大的项目,我唯一会考虑的替代方案是 PostgreSQL,因为我信任它也很喜欢它(速度快、文档完善,还有 JSON 支持等出色的特性)。

因为想尽量减少依赖(而且我不太喜欢大多数 ORM),所以这个项目我决定只使用标准库的 database/sql 包。我也短暂考虑过使用 sqlx——我确实喜欢它对结构体和切片的处理——但对于这么小的数据库模型来说,它带来的提升并不大。

下面是 db.go 中的两个函数,让你感受一下具体写法。GetList 用于获取单个清单,而辅助函数 getListItems 则获取该清单的所有条目:

// GetList fetches one list and returns it, or nil if not found.
func (m *SQLModel) GetList(id string) (*List, error) {
    row := m.db.QueryRow(`
        SELECT id, name
        FROM lists
        WHERE id = ? AND time_deleted IS NULL
        `, id)
    var list List
    err := row.Scan(&list.ID, &list.Name)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    list.Items, err = m.getListItems(id)
    return &list, err
}

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    rows, err := m.db.Query(`
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []*Item
    for rows.Next() {
        var item Item
        err = rows.Scan(&item.ID, &item.Description, &item.Done)
        if err != nil {
            return nil, err
        }
        items = append(items, &item)
    }
    return items, rows.Err()
}

可以看到,QueryRow 用于从表中获取单行数据,用法很直接。Query 则用于获取多行,稍微麻烦一些——你需要手动遍历结果集、逐行 Scan、检查扫描错误,并将扫描到的条目追加到切片中。哦,别忘了 Close 结果集对象。我尝试过寻找简化的方法,但在只用 database/sql 的情况下,如果不用反射,这已经是能做到的最好程度了(如果你有更好的办法请告诉我)。

尽量减少依赖固然好,但这确实是一种权衡。如果你需要执行大量查询,我会推荐使用前面提到的 sqlx 库。使用 sqlx 后,getListItems 辅助函数就可以省去所有遍历和 Scan 的样板代码:

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    var items []*Item
    err := m.db.Select(&items, `
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    return items, err
}

另一方面,用 database/sql 执行变更查询就很简单,因为不需要扫描行。下面是我如何从清单中删除条目的(我通常更倾向于软删除,以便日后可能恢复):

// DeleteItem (soft) deletes the given item in a list.
func (m *SQLModel) DeleteItem(listID, itemID string) error {
    _, err := m.db.Exec(`
            UPDATE items
            SET time_deleted = CURRENT_TIMESTAMP
            WHERE list_id = ? AND id = ?
        `, listID, itemID)
    return err
}

手动依赖注入

在展示具体的测试实现之前,我想先简单聊聊 Go 的接口和依赖注入。“注入依赖”本身是件好事,但依赖注入框架和库却让人头疼:它们通常使用运行时反射,绕过了类型检查,还让人在 IDE 中(甚至用眼睛)难以追踪代码。好在手动组装依赖既安全又简单。

在 Go 中,接口往往不是定义在提供实现的包里,而是定义在使用它的包里(而且接口可能只包含实现所提供方法的一个子集)。

Simple Lists 的代码结构非常扁平,所有内容都在一个包里:main。但为了展示在更大项目中会怎么做,我在 server.go 靠近使用处定义了一个数据库 Model 接口。下面是 Server 所需的接口,以及用于创建服务实例的 NewServer 函数的签名:

// Model is the database model interface used by the server.
type Model interface {
    GetLists() ([]*List, error)
    CreateList(name string) (string, error)
    DeleteList(id string) error
    GetList(id string) (*List, error)

    AddItem(listID, description string) (string, error)
    UpdateDone(listID, itemID string, done bool) error
    DeleteItem(listID, itemID string) error

    CreateSignIn() (string, error)
    IsSignInValid(id string) (bool, error)
    DeleteSignIn(id string) error
}

// Logger is the logger interface used by the server.
type Logger interface {
    Printf(format string, v ...interface{})
}

// NewServer creates a new server with the specified dependencies.
func NewServer(
    model Model,
    logger Logger,
    timezone string,
    username string,
    passwordHash string,
    showLists bool,
) (*Server, error) {
    ...
}

然后在 main.go 中,我们将所有依赖组装起来:

func main() {
    ...
    db, err := sql.Open("sqlite", *dbPath)
    exitOnError(err)
    model, err := NewSQLModel(db)
    exitOnError(err)
    server, err := NewServer(model, log.Default(), *timezone,
                             *username, passwordHash, *showLists)
    exitOnError(err)
    ...
}

在我看来(两种方式我都用过),在 main 中显式地组装所有依赖要好得多:这就是普通的 Go 代码,经过类型检查,对 IDE 也很友好。

为数据库模型使用接口,可以让我们轻松地为测试创建模拟或伪造的数据库实现。当生产代码使用 PostgreSQL 或 MongoDB 这类较重的外部数据库时,这很有用。不过在这个例子中,测试里使用的是真正的 SQLite 数据库(只是在内存中),所以甚至不需要写伪造实现。下面是在测试中组装服务的方式:

db, err := sql.Open("sqlite", ":memory:")
if err != nil {
    t.Fatalf("opening database: %v", err)
}
model, err := NewSQLModel(db)
if err != nil {
    t.Fatalf("creating model: %v", err)
}
server, err := NewServer(model, nullLogger{}, "Pacific/Auckland",
                         "", "", true)
if err != nil {
    t.Fatalf("creating server: %v", err)
}

测试

只要可能,我喜欢在测试中直接对接真实数据库。如果能低成本地在容器中启动一个 PostgreSQL,或者能使用内存数据库,那么你就是在针对真实环境进行测试。

在我们的例子中,使用的是内存中的 SQLite 数据库(:memory:),因此所有操作都在进程内完成,甚至不会触及磁盘。完全不需要编写伪造对象或使用 mock——我们的测试在 SQLite 上运行得非常快。如果生产环境用的是 PostgreSQL,我可能仍会在测试中使用 SQLite 来保持速度,只对两者有差异的查询做覆盖。

这些测试也相当接近端到端测试,测的是功能而非实现。每个测试都会调用 Server.ServeHTTP 处理器,并使用 net/http/httptest 包记录 HTTP 响应。

下面是测试的一段示例:

func TestServer(t *testing.T) {
    ...
    jar, err := cookiejar.New(nil)
    if err != nil {
        t.Fatalf("creating cookie jar: %v", err)
    }

    // Fetch homepage
    var csrfToken string // CSRF token stays same for entire session
    {
        recorder := serve(t, server, jar, "GET", "/", nil)

        ensureCode(t, recorder, http.StatusOK)
        forms := parseForms(t, recorder.Body.String())
        ensureInt(t, len(forms), 1)
        ensureString(t, forms[0].Action, "/create-list")
        csrfToken = forms[0].Inputs["csrf-token"]
        if csrfToken == "" {
            t.Fatal("csrf-token input not found")
        }
    }

    // Create list
    var listID string
    var listIDs []string
    {
        form := url.Values{}
        form.Set("csrf-token", csrfToken)
        form.Set("name", "Shopping List")
        recorder := serve(t, server, jar, "POST", "/create-list", form)

        ensureCode(t, recorder, http.StatusFound)
        location := recorder.Result().Header.Get("Location")
        ensureRegex(t, location, "/lists/[a-z]{10}")
        listID = location[7:]
        listIDs = append(listIDs, listID)
    }
    ...

这里我做了一个简化的设计选择,它可能不具备可扩展性:所有测试都写在同一个顺序执行的 TestServer 函数中,后面的测试部分依赖于前面的部分。我之所以能这么做,是因为测试运行得非常快(go test 显示只需 10 毫秒),这样也避免了在每个子测试开始时都要重复执行创建清单等公共准备步骤。

如果是在更大的代码库中,我会把准备步骤放到辅助函数里,并通过 子测试使用 t.Run 将测试的每个部分作为子测试来运行。

一遍又一遍地重复 if got != want { t.Fatalf("got %v, want %v", got, want) } 这样的代码很快就会让人厌烦,所以我把它们抽成了辅助函数。我也可以使用众多断言库中的一个(我比较偏爱 gocheck,因为它的 API 很小),但为了避免引入依赖,自己写几个小小的 ensure* 辅助函数也很简单。

因为这不是一个 JSON API,我写了一个 parseForms 辅助函数,使用 golang.org/x/net/html 包来解析响应体中的 HTML 表单。这样就能提取出各种字段供后续使用,比如 CSRF 令牌。

用于执行并记录请求的 serve 辅助函数如下:

// serve records a single HTTP request and returns the response recorder.
func serve(t *testing.T, server *Server, jar http.CookieJar,
           method, path string, form url.Values,
) *httptest.ResponseRecorder {
    t.Helper()
    var body io.Reader
    if form != nil {
        body = strings.NewReader(form.Encode())
    }
    r, err := http.NewRequest(method, "http://localhost"+path, body)
    if err != nil {
        t.Fatalf("creating request: %v", err)
    }
    if form != nil {
        r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    }
    for _, c := range jar.Cookies(r.URL) {
        r.Header.Add("Cookie", c.Name+"="+c.Value)
    }
    recorder := httptest.NewRecorder()
    server.ServeHTTP(recorder, r)
    jar.SetCookies(r.URL, recorder.Result().Cookies())
    return recorder
}

注意这里使用了 net/http/cookiejar,以确保将之前请求设置的 Cookie 传递给后续请求。CSRF Cookie 就是通过这种方式自动处理的。

这些 Go 测试覆盖了大多数基本的服务端功能。不过,它们不测试布局和界面,所以在做改动时,我也会在本地浏览器中快速手动测试一遍。Simple Lists 只有几个功能,全部过一遍也只需要一分钟。

极简的 HTML 与 CSS

我使用 Go 的 html/template 包来做 HTML 模板。它有点古怪(比如我更希望表达式语法是 Go 表达式的子集),但看过文档后用起来还不算糟。注意,大部分模板文档其实在 text/template 中。

我们的 HTML 非常简单:只有两个页面(首页和清单页)。html/template 包支持“块”来实现模板复用,但让两个模板之间有少许重复反而更简单。

可以查看完整的模板源码。请注意 meta viewport 标签,它让布局在手机上也能很好地显示——Simple Lists 是“完全响应式”的!

<meta name="viewport" content="width=device-width, initial-scale=1">

HTML 使用了少量的 CSS 来美化列表元素和按钮。对于这种规模的应用,我发现直接使用内联 CSS 最简单,例如去除边框并设置删除按钮的颜色。注意按钮标签使用了 Unicode 字符 ——还需要什么图标呢!

<button style="padding: 0 0.5em; border: none; background: none;
               color: #ccc" title="Delete Item">✕</button>

如果要做更大的应用,我可能会使用 CSS 类或其他机制,以便更轻松地复用这些样式并将它们集中定义在一处。

安全性

这个应用尚未经过安全审计,所以我不对安全性做任何保证,尽管我已经尽量小心。需要注意的几点如下:

  • 如前所述,它通过确保 csrf-token Cookie 与 csrf-token 表单字段一致来实现 CSRF 防护。
  • 它使用 Go 的模板库,该库会自动防范跨站脚本(XSS)攻击。
  • 它使用 Go 的 database/sql 库并配合参数化查询,从而可抵御 SQL 注入攻击。
  • SQLite 本身经过了大量测试,我们使用的 modernc.org/sqlite 包也针对同一套庞大的测试集进行了测试。
  • Go 的 HTTP 服务器以安全且久经生产环境考验而著称。

演示站点上用户名/密码认证是关闭的,但在我们家人使用的个人实例上我启用了它。只有一个用户名和密码,但对我个人使用来说已经足够。

在这种模式下,用户名通过命令行参数传给服务,而经 bcrypt 哈希后的密码则通过环境变量传入。显然,如果要支持多用户,我们会想把用户名和哈希后的密码存到数据库中。会话即“登录”记录,存储在简单的 sign_ins 数据表中,90 天后过期。

举个例子,下面是 signIn 请求处理器的代码。它从表单字段中获取用户名和密码,使用 bcryptCompareHashAndPassword 函数校验密码,如果登录有效,则创建 sign_ins 记录并设置 sign-in Cookie。

func (s *Server) signIn(w http.ResponseWriter, r *http.Request) {
    username := strings.TrimSpace(r.FormValue("username"))
    password := r.FormValue("password")
    returnURL := r.FormValue("return-url")
    if returnURL == "" {
        returnURL = "/"
    }
    if username != s.username || bcrypt.CompareHashAndPassword(
            []byte(s.passwordHash), []byte(password)) != nil {
        location := "/?error=sign-in&return-url=" +
            url.QueryEscape(returnURL)
        http.Redirect(w, r, location, http.StatusFound)
        return
    }
    id, err := s.model.CreateSignIn()
    if err != nil {
        s.internalError(w, "creating sign in", err)
        return
    }
    cookie := &http.Cookie{
        Name:     "sign-in",
        Value:    id,
        MaxAge:   90 * 24 * 60 * 60,
        Path:     "/",
        Secure:   r.URL.Scheme == "https",
        HttpOnly: true,
        SameSite: http.SameSiteStrictMode,
    }
    http.SetCookie(w, cookie)
    http.Redirect(w, r, returnURL, http.StatusFound)
}

结语

我非常享受构建 Simple Lists 的过程,它已经为我们家的共享清单派上了用场:生日和圣诞清单、待看电影清单等等。我喜欢保持小而快、轻量化的风格,我相信在这里已经做到了。

使用 Go 非常愉快:静态类型检查、快速的编译速度、优秀的标准库,以及出色的格式化和测试工具。而且交叉编译和部署极其简单。只需执行 GOOS=linux GOARCH=amd64 go build,几秒钟后(即使你在 macOS 或 Windows 上开发)就能得到一个可复制到生产服务器的 Linux 可执行文件。

我也很喜欢它在完全不用 JavaScript 的情况下依然相当好用。如今 HTML 本身就能做很多事,所以在搬出 React 之前,不妨三思。

希望你喜欢这篇文章或从中学到了些东西。如有任何反馈,欢迎告诉我!

本文章由 muse-spark-1.2-contributor 进行翻译

评论