使用 Gin 构建 Go Web 应用程序

1,131 阅读18分钟

基本介绍

通过本教程,你将学会使用 Gin 框架构建传统的 Web 应用。我们将构建一个展示文章列表和文章详情的简单应用。

为什么使用 Gin

Go 语言内置的 net/http 库虽然具有了路由注册功能,并且可以启动监听端口,实现一个简单的 Web 服务,但是 net/http 本身提供的功能还是比较简单、原始,而且暴露的函数签名是 func(ResponseWriter, *Request) ,开发者解析请求和回写响应都不是很方便,因此产生了很多 HTTP Web 框架。Gin 正是其中之一,之所以选用这个框架原因如下:

  • 快:使用路由 httprouter(基于高性能的 Radix Tree(前缀树的压缩版)实现)
  • 中间件:方便、灵活,支持自定义,这里 是官方提供的一些常用的中间件
  • gin.Context:内容丰富的上下文对象
  • 强大的数据绑定
  • 市场占有率高
  • 活跃的开发者,有较为丰富的生态
  • ...

准备工作

开始之前,你需要安装 gocurl,这里我们使用的是 go 1.12.5 版本,如果没有 curl 你也可以使用其它用于测试 API 的工具。

功能列表

我们将构建一个文章管理程序,这个程序包含的功能有:

  • 通过用户名、密码注册(未登录用户)
  • 通过用户名、密码登录(未登录用户)
  • 用户登出(登录用户)
  • 创建文章(登录用户)
  • 主页展示所有文章列表(登录和未登录用户)
  • 展示单个文章内容(登录和未登录用户)

另外,文章列表和文章内容可以通过 HMTL、JSON 或者 XML 等形式访问。

程序设计

我们来看下 Gin 是如何处理一个请求的。典型的 Web 服务器、API 服务或者微服务的处理流程如下:

请求到达 -> 路由解析 -> [可选的中间件] -> 业务处理 -> [可选的中间件] -> 响应渲染

路由

路由是现代 Web 框架的关键特性。任何 Web 页面或者 API 都需要通过 URL 来访问。框架一般都通过路由来处理请求,如果有个这样的 URL:http://www.example.com/some/random/route,那么它的路由就是:/some/random/route

Gin 提供了高效地路由能力可以轻松地配置和使用。除了具体的 URL,Gin 还可以处理模式路由和分组路由。

在我们这个应用中,我们将使用如下的规则:

  • 首页使用/(GET)
  • 将用户相关的功能聚合到/u 分组下
    • 登录页面使用/u/login(GET)
    • 登录验证使用/u/login(POST)
    • 登出使用/u/logout(GET)
    • 注册页面使用/u/register(GET)
    • 注册信息处理使用/u/register(POST)
    • 有想过
  • 将文章相关的功能聚合到/article分组下
    • 创建文章页面/article/create(GET)
    • 提交文章处理/article/create(POST)
    • 文章详情页面/article/view/:article_id(GET),注意:该路由中有个:article_id,开始的:表示这是个动态路由,:article_id可以表达任何值,Gin 会将这个变量传递给路由处理器

中间件

在 Go Web 应用上下文中,中间件是处理请求过程中的任意阶段可被执行的代码段。它通常用来封装在多个路由都会被用到的公共逻辑。中间件可以作用在请求处理之前和(或)之后,典型的应用场景包括鉴权、参数校验等。

如果在处理请求之前使用了中间件,中间件对请求所做的任何更改将在路由处理器中可用,当我们要对某些请求进行一些验证时将会很方便。另一方面,如果在路由处理器之后使用中间件,则中间件将能拿到路由处理器返回的响应,这可以用来修改响应。

Gin 允许我们自定义中间件,中间件能被多个路由所共享。这样使得代码库保持较小、聚焦业务并提高了代码可维护性。

在这个应用中,我们要确保某些页面和行为例如创建文章、注销仅对已登录的用户可用,注册、登录仅对未登录用户可用,这里可以定义中间件 ensureNotLoggedInensureNotLoggedIn ,路由按需使用。

我们也可以定义将作用于所有路由的中间件。例如中间件 setUserStatus ,它将检查请求是否来自经过身份验证的用户然后设置一个标志,可于控制模板中某些菜单的可见性。

渲染

Web 应用可以对响应作不同格式的渲染,例如 HTML、文本、JSON、XML 等。API 服务或者微服务通常以 JSON 格式返回数据,当然根据需要也可以用其他格式。在接下来的内容中,我们将使用可重用的代码呈现不同类型的响应。

安装依赖

这个应用中使用到的唯一外部依赖就是 Gin 框架了,我们可以使用如下命令安装最新版本:

go get -u github.com/gin-gonic/gin

创建基础模板

我们将使用模板来展示 Web 页面,在所有的页面中通常包含页头、菜单栏、侧边栏和页尾。Go 允许我们创建可重用的模板片段,这些片段可以导入其他模板中。

页头和页尾是所有模板中重复使用的通用部分。我们单独创建个菜单模板,在页头中将其导入,最后在首页模板中导入页头和页尾。所有的模板文件将会放到项目的templates目录下。

先按照如下创建菜单模板templates/menu.html

<!--menu.html-->

<nav class="navbar navbar-default">
  <div class="container">
    <div class="navbar-header">
      <a class="navbar-brand" href="/">
        Home
      </a>
    </div>
  </div>
</nav>

初始状态下,菜单只包含首页的链接,随着功能的增加可以继续往里面添加内容。

然后创建页头模板templates/ header.html

<!--header.html-->

<!doctype html>
<html>

  <head>
    <title>{{ .title }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="UTF-8">

    <!--Use bootstrap to make the application look nice-->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
    <script async src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous"></script>
  </head>

  <body class="container">
    {{ template "menu.html" . }}

我们在模板中使用了开源的 Bootstrap 框架,这个框架可以帮助我们快速编写美观、响应式的 HTML 页面。模板内容大部分是标准的 HTML,其中有两行需要注意下,第一个是<title>{{ .title }}</title>行,其中的.title是用来动态设置页面标题的,这个变量会在程序中设置。第二个是包含{{ template "menu.html" . }}的行,表示从menu.html中导入菜单模板,这也是 Go 导入其它模板的语法。

页尾模板是个静态的 HTML,我们会在首页模板中使用页头和页尾,并显示一条简单的 Hello Gin 文本:

{{ template "header.html" .}}
  <h1>Hello Gin!</h1>
{{ template "footer.html" .}}

其它的页面以可以用这种方式去复用页头和页尾模板。

初始设置并验证

模板写好后,我们新建个main.go文件,然后基于首页来创建个最简单的 Web 应用,通过以下四个步骤使用 Gin 来实现。

创建路由器

在 Gin 中默认的创建路由器方式如下:

router := gin.Default()

加载模板

创建路由后,你可以这样加载所有的模板:

router.LoadHTMLGlob("templates/*")

这将加载templates目录下所有的模板文件,加载完成后,你在处理请求时就不用重复加载了,这样可以 web 应用运行的更快。

定义路由处理器

Gin 的核心就是如何将应用划分为各种路由,并为每个路由定义处理器。我们将为首页创建一个路由以及相应的内联处理器。

router.GET("/", func(c *gin.Context) {

  // Call the HTML method of the Context to render a template
  c.HTML(
      // Set the HTTP status to 200 (OK)
      http.StatusOK,
      // Use the index.html template
      "index.html",
      // Pass the data that the page uses (in this case, 'title')
      gin.H{
          "title": "Home Page",
      },
  )
  
})

router.GET方法定义了一个 GET 请求的路由处理器,它以路由(/)和一个或多个函数形式的路由处理器作为参数。

路由处理器以上下文对象指针(gin.Context)作为参数,上下文包含了待处理请求的所有信息,例如请求头、cookie 等。

上下文提供了一些方法用来渲染响应结果,例如 HTML、文本、JSON 和 XML 格式。上面代码中使用了context.HTML方法用来渲染 HTML 模板(index.html),调用这个方法可以附加一些额外数据(title),在模板中我们可以直接使用这些数据,例如用来在主页的页头中展示标题。

启动应用

通过路由器的Run方法来启动应用:

router.Run()

默认情况下将会在本地的localhost下启动一个监听8080端口的服务。完整的main.go文件内容如下:

package main

import (
  "net/http"

  "github.com/gin-gonic/gin"
)

var router *gin.Engine

func main() {
  router = gin.Default()
  router.LoadHTMLGlob("templates/*")
  router.GET("/", func(c *gin.Context) {
    c.HTML(
      http.StatusOK,
      "index.html",
      gin.H{
        "title": "Home Page",
      },
    )
  })
  router.Run()
}

我们可以在命令行运行这个应用,进入到应用所在目录,然后执行以下命令:

go build -o app

这个命令会构建应用然后生成一个可执行文件app,然后启动应用:

./app

如果一切顺利,你在浏览器上通过http://localhost:8080即可访问应用程序,并且显示所下:

至此,你的项目的结构如下:

├── main.go
└── templates
    ├── footer.html
    ├── header.html
    ├── index.html
    └── menu.html

显示文章列表

接下来我们将在主页上添加一个显示所有文章的列表的功能。

设置路由

在之前的内容中,我们在main.go中定义了路由,但是随着应用的功能增加,我们将所有的路由定义放到单独的文件会比较合理。我们可以在routes.go中定义initializeRoutes()方法,然后在main()中调用这个方法去初始化所有的路由,另外我们不再使用内联函数的形式去定义路由,而且放到单独的方法,这样更加便于管理。

经过重构后,routes.go内容如下:

package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

func initializeRoutes() {
	router.GET("/", showIndexPage)
}

因为我们要在首页中展示所有的文章列表,因此无需定义新的路由,main.go此时内容如下:

package main

import "github.com/gin-gonic/gin"

var router *gin.Engine

func main() {
	router = gin.Default()
	router.LoadHTMLGlob("templates/*")
	initializeRoutes()
	router.Run()
}

设计文章模型

我们将尽量地使文章对象简单,仅使用三个字段来表示IdTitleContent,结构体如下:

type article struct {
	ID      int    `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

很多应用会使用数据库来持久化数据,为了简单起见,我们会在内存中维护这些数据,初始状态下只有两篇文章:

var articleList = []article{
	{ID: 1, Title: "Article 1", Content: "Article 1 body"},
	{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}

我们将把上面代码放到一个新的文件中并命名为models_article.go,我们还需要个函数用来返回文章列表,我们将其命名为getAllArticles()并放入到该文件中,另外还需要为其编写一个单元测试,测试名为TestGetAllArticles,并将其放到到models_article_test.go

此时,models_article.go内容如下:

package main

var articleList = []article{
	{ID: 1, Title: "Article 1", Content: "Article 1 body"},
	{ID: 2, Title: "Article 2", Content: "Article 2 body"},
}

type article struct {
	ID      int    `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

func getAllArticles() []article {
	return articleList
}

models_article_test.go内容如下:

package main

import (
	"testing"
)

// Test the function that fetches all articles
func TestGetAllArticles(t *testing.T) {
	alist := getAllArticles()

	// Check that the length of the list of articles returned is the
	// same as the length of the global variable holding the list
	if len(alist) != len(articleList) {
		t.Fail()
	}

	// Check that each member is identical
	for i, v := range alist {
		if v.Content != articleList[i].Content ||
			v.ID != articleList[i].ID ||
			v.Title != articleList[i].Title {
			t.Fail()
			break
		}
	}
}

单元测试的对象是getAllArticles()方法,用来保证获取到的文章列表和全局变量articleList内容一致。

创建视图模板

因为要在首页展示文章列表,所以需要对index.html中的内容进行替换。假设传入模板的文章列表为payload,我们使用如下代码段对列表作展示:

{{range .payload }}
	<a href="/article/view/{{.ID}}">
		<h2>{{.Title}}</h2>
	</a>
	<p>{{.Content}}</p>
{{end}}

代码通过{{range}}语法对payload进行遍历用来展示每篇文章的标题和内容,标题上还有指向单个文章的链接,由于我们尚未初始展示单个文章的路由,因此这些链接暂时还没用。

此时index.html的完整内容如下:

{{ template "header.html" .}}
  {{range .payload }}
    <a href="/article/view/{{.ID}}">
      <h2>{{.Title}}</h2>
    </a>
    <p>{{.Content}}</p>
  {{end}}
{{ template "footer.html" .}}

单元测试

我们需要对首页路由编写单元测试,用来保证它的行为符合预期,测试点如下:

  • 返回正确的 HTTP 响应码 200
  • 返回的 HTML 内容包含页面标题并且标题内容为Home Page

我们将在handler_article_test.go中的TestShowIndexPageUnauthenticated编写测试,另外我们将测试中使用的工具方法抽取到common_test.go中,减少样板代码。

handler_article_test.go的内容如下:

package main

import (
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// Test that a GET request to the home page returns the home page with
// the HTTP code 200 for an unauthenticated user
func TestShowIndexPageUnauthenticated(t *testing.T) {
	r := getRouter(true)

	r.GET("/", showIndexPage)

	// Create a request to send to the above route
	req, _ := http.NewRequest("GET", "/", nil)

	testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder) bool {
		// Test that the http status code is 200
		statusOK := w.Code == http.StatusOK

		// Test that the page title is "Home Page"
		// You can carry out a lot more detailed tests using libraries that can
		// parse and process HTML pages
		p, err := ioutil.ReadAll(w.Body)
		pageOK := err == nil && strings.Index(string(p), "<title>Home Page</title>") > 0

		return statusOK && pageOK
	})
}

common_test.go的内容如下:

package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
)

var tmpArticleList []article

// This function is used for setup before executing the test functions
func TestMain(m *testing.M) {
	// Set Gin to Test Mode
	gin.SetMode(gin.TestMode)

	// Run the other tests
	os.Exit(m.Run())
}

// Helper function to create a router during testing
func getRouter(withTemplates bool) *gin.Engine {
	r := gin.Default()
	if withTemplates {
		r.LoadHTMLGlob("templates/*")
	}
	return r
}

// Helper function to process a request and test its response
func testHTTPResponse(t *testing.T, r *gin.Engine, req *http.Request, f func(w *httptest.ResponseRecorder) bool) {

	// Create a response recorder
	w := httptest.NewRecorder()

	// Create the service and process the above request.
	r.ServeHTTP(w, req)

	if !f(w) {
		t.Fail()
	}
}

// This function is used to store the main lists into the temporary one
// for testing
func saveLists() {
	tmpArticleList = articleList
}

// This function is used to restore the main lists from the temporary one
func restoreLists() {
	articleList = tmpArticleList
}

TestMain函数将 Gin 设置为测试模式然后调用其它的测试方法。getRouter函数以类似于主程序的方式创建并返回路由器。saveLists()函数将原始的文章列表保存到了一个临时变量。当测试结束时这个临时变量会通过restoreLists()函数还原为初始状态。

最后,testHTTPResponse函数执行传入的函数以查看它的返回结果,为 true 表示表示测试成功,false 表示测试失败。这个函数有助于我们避免重复测试 HTTP 请求的响应所需的代码。

当需要检查 HTTP 状态码和返回的 HTML,我们可以按照如下操作:

  • 创建一个新的路由器
  • 定义路由以使用与主应用相同的路由处理器(showIndexPage)
  • 创建一个新的请求访问这个路由
  • 创建一个函数用处理响应结果以测试 HTTP 状态码、HTML 等
  • 调用testHTTPResponse()并传入上面的函数以完成测试

创建路由处理器

我们将在handlers.article.go创建与文章相关功能的路由处理器,showIndexPage需要获取所有文章并渲染index.htmlhandlers.article.go中实现的代码如下:

package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func showIndexPage(c *gin.Context) {
	articles := getAllArticles()

	c.HTML(
		http.StatusOK,
		"index.html",
		gin.H{
			"title":   "Home Page",
			"payload": articles,
		},
	)
}

重新构建并运行程序,通过浏览器访问 http://localhost:8080 将如下所示:

显示文章详情

接下来我们将在主页上添加一个点击文章链接跳转到文章详情的功能。

设置路由

我们可以按照之前的方式增加新的路由,由于所有文章详情的处理逻辑是一样的,但是每个文章的 URL 不一样,Gin 中通过参数路由来解决这个问题,新的 routes.go 内容如下:

package main

func initializeRoutes() {
	router.GET("/", showIndexPage)
	// 参数路由
	router.GET("/article/view/:article_id", getArticle)
}

创建视图模板

新建 article.html 内容如下,此时模板中的 payload 表示单个文章对象。

{{ template "header.html" .}}

<h1>{{.payload.Title}}</h1>

<p>{{.payload.Content}}</p>

{{ template "footer.html" .}}

单元测试

测试点如下:

  • 返回正确的 HTTP 响应码 200
  • 返回的 HTML 内容包含页面标题,并且标题内容是文章标题 我们将在handler_article_test.go中新增 TestArticleUnauthenticated 函数用来测试:
func TestArticleUnauthenticated(t *testing.T) {
	r := getRouter(true)

	r.GET("/article/view/:article_id", getArticle)

	req, _ := http.NewRequest("GET", "/article/view/1", nil)

	testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder) bool {
		statusOK := w.Code == http.StatusOK

		p, err := ioutil.ReadAll(w.Body)
		pageOK := err == nil && strings.Index(string(p), "<title>Article 1</title>") > 0

		return statusOK && pageOK
	})
}

创建路由处理器

由于路由中有动态的内容,我们使用如下方法获取文章的 ID:

c.Param("article_id")

c 是 Gin 上下文对象指针。此外,需要一个根据文章 Id 获取文章详情的函数 getArticleByID,在 models_article.go 中增加下代码:

func getArticleByID(id int) (*article, error) {
  for _, a := range articleList {
    if a.ID == id {
      return &a, nil
    }
  }
  // 当传入的文章 Id 不存在时,我们需要返回一个错误。
  return nil, errors.New("Article not found")
}

这时我们在 handlers_article.go 中增加如下的路由处理器:

func getArticle(c *gin.Context) {
	if articleID, err := strconv.Atoi(c.Param("article_id")); err == nil {
		if article, err := getArticleByID(articleID); err == nil {
			c.HTML(
				http.StatusOK,
				"article.html",
				gin.H{
					"title":   article.Title,
					"payload": article,
				},
			)
		} else {
			c.AbortWithError(http.StatusNotFound, err)
		}
	} else {
		c.AbortWithStatus(http.StatusNotFound)
	}
}

重新构建并运行程序,通过浏览器访问 http://localhost:8080/article/view/1 将如下所示:

响应多样化

在这一部分,我们将对代码做个重构,根据请求头我们的应用可以以HTML,JSON或XML格式进行响应。

创建通用函数

我们将在 main.go 增加一个 render 函数用来将响应渲染成不同格式。在 HTTP 标准中请求可以通过 Accept 请求头标识自己所需的响应格式,常用的有如下几种:

  • application/json JSON 格式
  • application/xml XML 格式
  • 没有设置值时默认以 HTML 格式返回

在 Gin 中我们通过如下方法获取请求头的值

// c is the Gin Context
c.Request.Header.Get("Accept")

render 函数如下:

func render(c *gin.Context, data gin.H, templateName string) {
	switch c.Request.Header.Get("Accept") {
	case "application/json":
		c.JSON(http.StatusOK, data["payload"])
	case "application/xml":
		c.XML(http.StatusOK, data["payload"])
	default:
		c.HTML(http.StatusOK, templateName, data)
	}
}

单元测试

测试点如下:

  • Accept 头值为 application/json 时,响应内容的格式为 JSON
  • Accept 头值为 application/xml 时,响应内容的格式为 XML

handlers.article_test.go 新增 TestArticleListJSONTestArticleXML 函数用来测试:

func TestArticleListJSON(t *testing.T) {
	r := getRouter(true)
	r.GET("/", showIndexPage)
	req, _ := http.NewRequest("GET", "/", nil)
	req.Header.Add("Accept", "application/json")
	testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder) bool {
		statusOK := w.Code == http.StatusOK
		p, err := ioutil.ReadAll(w.Body)
		if err != nil {
			return false
		}
		var articles []article
		err = json.Unmarshal(p, &articles)
		return err == nil && len(articles) >= 2 && statusOK
	})
}

func TestArticleXML(t *testing.T) {
	r := getRouter(true)
	r.GET("/article/view/:article_id", getArticle)

	req, _ := http.NewRequest("GET", "/article/view/1", nil)
	req.Header.Add("Accept", "application/xml")

	testHTTPResponse(t, r, req, func(w *httptest.ResponseRecorder) bool {
		statusOK := w.Code == http.StatusOK
		p, err := ioutil.ReadAll(w.Body)
		if err != nil {
			return false
		}
		var a article
		err = xml.Unmarshal(p, &a)
		return err == nil && a.ID == 1 && len(a.Title) >= 0 && statusOK
	})
}

更新路由处理器

原有的路由处理器不需要进行太多更改,只需要将原来的 c.HTML 方法替换为 render 函数即可。例如:

func showIndexPage(c *gin.Context) {
	articles := getAllArticles()
	// c.HTML -> render
	render(c, gin.H{
		"title":   "Home Page",
		"payload": articles}, "index.html")
}

重新构建并运行程序,为了验证效果,我们使用 curl 工具进行测试,在命令行中输入如下验证 JSON 格式:

curl -X GET -H "Accept: application/json" http://localhost:8080/

返回如下表示成功:

[
  {
    "id": 1,
    "title": "Article 1",
    "content": "Article 1 body"
  },
  {
    "id": 2,
    "title": "Article 2",
    "content": "Article 2 body"
  }
]

XML 验证同理,不在赘述。

应用测试

由于我们一直在使用测试来为路由处理器和模型创建规范,因此我们应该不断运行它们以确保功能能够按预期运行。现在,我们运行我们编写的测试并查看结果。在项目目录中,执行以下命令:

go test -v

一切顺利的话我们将会得到类似以下的执行结果:

=== RUN   TestShowIndexPageUnauthenticated
[GIN] 2010/01/04 - 19:07:26 | 200 |     183.315µs |  |   GET     /
--- PASS: TestShowIndexPageUnauthenticated (0.00s)
=== RUN   TestArticleUnauthenticated
[GIN] 2010/01/04 - 19:07:26 | 200 |     143.789µs |  |   GET     /article/view/1
--- PASS: TestArticleUnauthenticated (0.00s)
=== RUN   TestArticleListJSON
[GIN] 2010/01/04 - 19:07:26 | 200 |      51.087µs |  |   GET     /
--- PASS: TestArticleListJSON (0.00s)
=== RUN   TestArticleXML
[GIN] 2010/01/04 - 19:07:26 | 200 |      38.656µs |  |   GET     /article/view/1
--- PASS: TestArticleXML (0.00s)
=== RUN   TestGetAllArticles
--- PASS: TestGetAllArticles (0.00s)
=== RUN   TestGetArticleByID
--- PASS: TestGetArticleByID (0.00s)
PASS
ok    github.com/zhoulaosan0/go-gin-app 0.084s

在输出的结果中我们可以看到,这个命令执行了我们编写的所有测试,表示我们的程序正在按预期的方式工作。如果仔细观察的话,您会注意到 Go 在测试路由处理器的过程中发出了 HTTP 请求。

总结

在本教程中,我们使用 Gin 创建了一个新的 Web 应用程序,并逐步添加了更多的功能。我们使用测试来构建健壮的路由处理器,并了解了如何重用相同的代码以最小的努力呈现多种格式的响应。按照之前的功能规划,我们还有以下功能尚未完成:

  • 注册
  • 登录登出
  • 新增文章
  • 身份认证

这个 Github 仓库 提供了实现所有功能的完整代码并且可以直接运行。页面效果如下:

如果你有任何问题或意见,欢迎在文章下方随时评论。