基于 Go 语言开发在线论坛

(一):整体设计与数据模型

通过一个简单的在线论坛项目帮助大家从项目实际开发维度快速了解 Go Web 编程的全貌,然后再各个击破,深入介绍请求、响应、视图、数据库、Web 服务、测试、部署等各个模块的细节。

功能需求

话不多说,直奔主题,我们这个在线论坛项目仿照 Google 网上论坛进行开发:

因此,这个在线论坛需要具备用户认证功能(注册、登录、退出等),认证后的用户才能创建新的群组、以及在群组中发表主题,访客用户访问论坛首页可以查看群组列表,进入指定群组页面可以查看对应的主题信息。

技术方案

整体功能很简单,接下来,我们按照这个功能需求设计技术方案。

其实就是一个很典型的 MVC 架构,以群组详情页为例,假设对应的 URL 是 http://chitchat.test/thread/read?id=123,其中 chitchat.test 是请求域名,thread/read 是请求路由(查看群组),?id=123 是请求参数(群组ID),通过域名确定应用所在的服务器 IP,通过端口号(此处没有显式展示,一般默认是 80 端口)确定应用进程,应用接收到请求后,通过路由将请求分发到指定处理器方法(之前介绍的路由器,或者叫做多路复用器做的就是这个工作,路由器是整个应用请求分发的入口),通过请求参数对数据库进行查询,再将视图响应发送给请求用户,如果数据库查询没有结果,则返回 404 响应。这里,数据库承担的是 M 的角色(Model),视图响应承担的是 V 的角色(View),处理器方法承担的是 C 的角色(Controller):

注:上图中 Client 代表客户端发起请求的用户,虚框内是部署在服务器已启动的在线论坛应用,Multiplexer 代表路由器(比如 gorilla/mux ),Handler 代码处理器/处理器方法,数据库操作位于处理器方法中,Templates 代表最终展示给用户的经过模板引擎编译过的视图模板。

其他页面和操作的请求/响应模型与此一致,不再重复介绍。

所以我们需要在本地按照这个 MVC 架构基于业务流程编写代码,最后将测试过的应用代码编译打包,部署到远程服务器(这样才能被普通用户访问),并启动该应用,等待客户端请求,这样就完成了整个应用开发流程。

数据模型

整体技术方案敲定后,接下来,我们就要按照流程编写代码了,在此之前,还需要确定好数据模型。

根据我们之前拟定的需求,至少需要三个模型:

  • 用户(User)
  • 群组(Thread)
  • 主题(Post)

另外,我们在本项目开发时,会把用户会话(Session)也存储到数据库,所以需要一个额外的会话模型,此外,为了简化应用,我们不会真的像 Google 网上论坛那样对用户做权限管理,整个应用只包含一种用户类型,并且具备所有操作权限:

做好上述准备工作后,接下来,就可以创建对应的数据表和模型类并编写相应的数据库交互实现了。

(二):通过模型类与 MySQL 数据库交互

在本篇教程中,我们将在 MySQL 中创建一个 chitchat 数据库作为论坛项目的数据库。你可以本地安装 MySQL 数据库,也可以基于 Docker 容器运行。

项目初始化

开始之前,我们先来初始化项目目录,我们将项目名设置为 chitchat,所以在 C:/Project/golang/src/github.com/xueyuanjun 目录下创建这个项目目录,然后初始化目录结构如下:

重点看下红框内,各个子目录/文件的作用介绍如下:

  • main.go:应用入口文件
  • config.json:全局配置文件
  • handlers:用于存放处理器代码(可类比为 MVC 模式中的控制器目录)
  • logs:用于存放日志文件
  • models:用于存放与数据库交互的模型类
  • public:用于存放前端资源文件,比如图片、CSS、JavaScript 等
  • routes:用于存放路由文件和路由器实现代码
  • views:用于存放视图模板文件

接下来,我们在 chitchat 目录下运行如下命令初始化 go.mod,因为我们后续通过 Go Module 来管理依赖:

go mod init github.com/xueyuanjun/chitchat

创建数据表

开始正式编码之前,现在 chitchat 数据库中创建数据表,对应的 SQL 语句如下:

create table users (
  id         serial primary key,
  uuid       varchar(64) not null unique,
  name       varchar(255),
  email      varchar(255) not null unique,
  password   varchar(255) not null,
  created_at timestamp not null
);
    
create table sessions (
  id         serial primary key,
  uuid       varchar(64) not null unique,
  email      varchar(255),
  user_id    integer references users(id),
  created_at timestamp not null
);
    
create table threads (
  id         serial primary key,
  uuid       varchar(64) not null unique,
  topic      text,
  user_id    integer references users(id),
  created_at timestamp not null
);
    
create table posts (
  id         serial primary key,
  uuid       varchar(64) not null unique,
  body       text,
  user_id    integer references users(id),
  thread_id  integer references threads(id),
  created_at timestamp not null
);

在 MySQL 客户端连接到 chitchat 数据库,运行上述 SQL 语句创建所有数据表:

与数据库交互

数据库驱动

数据表创建完成后,接下来,如何在 Go 应用代码中与数据库交互呢?Go 语言开发组并没有为此提供官方的数据库驱动实现,只是提供了数据库交互接口,我们可以通过实现这些接口的第三方扩展包完成与 MySQL 数据库的交互,本项目选择的扩展包是 go-mysql-driver 。

我们可以在 Go 应用中编写模型类基于这个扩展包提供的方法与 MySQL 交互完成增删改查操作,开始之前,可以运行如下命令安装这个依赖:

go get github.com/go-sql-driver/mysql

数据库连接

然后在 chitchat/models 目录下创建 db.go,并编写数据库连接初始化方法以及生成 UUID、哈希加密方法:

package models

import (
	"crypto/rand"
	"crypto/sha1"
	"database/sql"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"log"
)

// Db 变量代表数据库连接池
// 该变量首字母大写,方便在 models 包之外被引用
// 具体的操作实现我们放到独立的模型文件中处理
var Db *sql.DB

// 数据库连接初始化方法
// 通过 init 方法在 Web 应用启动时自动初始化数据库连接
// 在应用中通过 Db 变量对数据库进行增删改查操作
func init() {
	var err error
	// 通过 sql.Open 初始化数据库连接,我们写死了数据库连接配置,
	// 在实际生产环境,这块配置值应该从配置文件或系统环境变量获取。
	Db, err = sql.Open("mysql", "root@1234/chitchat?chartset=utf8&parseTime=true")
	if err != nil {
		log.Fatal(err)
	}
	return
}

// 生成UUID
// create a random UUID with from RFC 4122
// adapted from http://github.com/nu7hatch/gouuid
func createUUID() (uuid string) {
	u := new([16]byte)
	_, err := rand.Read(u[:])
	if err != nil {
		log.Fatalln("Cannot gengerate UUID", err)
	}

	// 0X40 is reserved variant from RFC 4122
	u[8] = (u[8] | 0x40) & 0x7f
	// Set the four most significant bits (bits 12 through 15) of the
	// time_hi_and_version field to the 4-bit version number.
	u[6] = (u[6] & 0xF) | (0x4 << 4)
	uuid = fmt.Sprintf("%x-%x-%x-%x-%x", u[0:4], u[4:6], u[6:8], u[8:10], u[10:])
	return
}

// 哈希加密方法
// hash plaintext with SHA-1
func Encrypt(plaintext string) (cryptext string) {
	cryptext = fmt.Sprintf("%x", sha1.Sum([]byte(plaintext)))
	return
}

用户相关模型类

有了代表数据库连接池的 Db 变量之后,就可以为每个数据表编写对应的模型类实现增删改查操作了,首先在 models 目录下创建 user.go 用于定义用户模型类 User 与 users 表进行交互,以及与 sessions 表进行关联:

package models

import "time"

// Go中面向对象是通过struct来实现
// struct是用户自定义的类型
// 首先需要定义struct
type User struct {
	Id       int
	Uuid     string
	Name     string
	Email    string
	Password string
	CreateAt time.Time
}

// Create a new session for an existing user
func (user *User) CreateSession() (session Session, err error) {
	// 使用statement 增加数据
	statement := "insert into sessions (uuid, email, user_id, create_at) values (?, ?, ?, ?)"
	// 使用Db.Prepare预处理语句获取一个 sql 执行模板,其中的?为需要输入的参数,之后通过stmtin.Exec()添加参数。
	stmtin, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	// defer语句是Go中一个非常有用的特性,可以将一个方法延迟到包裹该方法的方法返回时执行
	defer stmtin.Close()

	uuid := createUUID()
	stmtin.Exec(uuid, user, Email, user.Id, time.Now())

	stmtout, err := Db.Prepare("select id, uuid, emial,user_id, create_at from sessions where uuid = ?")
	if err != nil {
		return
	}
	// 使用defer关键字,在启用操作的时候直接在下一行加上defer *.close()函数,return的时候会执行相关的关闭函数。
	defer stmtout.Close()
	// use QueryRow to return a row and scan the returned id into the Session struct
	// 使用QueryRow返回一行并将返回的id扫描到Session结构中
	err = stmtout.QueryRow(uuid).Scan(&session.Id, &session.Uuid, &session.Email, &session.UserId, &session.CreateAt)
	return
}

// Get the session for an existing user
func (user *User) Session() (session Session, err error) {
	session = Session{}
	err = Db.QueryRow("SELECT id, uuid, email, user_id, create_at FROM sessions WHERE user_id = ?", user.Id).Scan(&session.Id, &session.Uuid, &session.Email, &session.UserId, &session.CreatedAt)
	return
}

// Create a new user, save user info into database
func (user *User) Create() (err error) {
	// Postgres does not automatically return the last insert id, because it would be wrong to assume
	// you're always using a sequence.You need to use the RETURNING keyword in your insert to get this
	// information from postgres.
	statement := "insert into users (uuid, name, email, password, create_at) values (?, ?, ?, ?, ?)"
	stamtin, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	defer stmtin.Close()

	uuid := createUUID()
	stmtin.Exec(uuid, user.Nmae, user.Email, Encrypt(user.Passeord), time.Now())

	stmtout, err := Db.Prepare("select id, uuid, create_at from users where uuid = ?")
	if err != nil {
		return
	}
	defer stmtout.Close()
	// use QueryRow to return a row and scan the returned id into the User struct
	err = stmtout.QueryRow(uuid).Scan(&user.Id, &user.Uuid, &user.CreatedAt)
	return
}

// Delete user from database
func (user *User) Delete() (err error) {
	statement := "delete from users where id = ?"
	stmt, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	defer stmt.Close()

	_, err = stmt.Exec(user.Id)
	return
}

// Update user information in the database
func (user *User) Update() (err error) {
	statement := "update users set name = ?, email = ? where id = ?"
	stmt, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	defer stmt.Close()

	_, err = stmt.Exec(user.Name, user.Email, user.Id)
	return
}

// Delete all users from database
func UserDeleteAll() (err error) {
	statement := "delete from users"
	_, err = Db.Exec(statement)
	return
}

// Get all users in the database and returns it
func Users() (users []User, err error) {
	rows, err := Db.Query("SELECT id, uuid, name, email, password, create_at FROM users")
	if err != nil {
		return
	}
	for rows.Next() {
		user := User{}
		if err = rows.Scan(&user.Id, &user.Uuid, &user.Name, &user.Email, &user.Password, &user.CreateAt); err != nil {
			return
		}
		users = append(users, user)
	}
	rows.Close()
	return
}

// Get a single user given the email
func UserByEmail(email string) (user User, err error) {
	user = User{}
	err = Db.QueryRow("SELECT id, uuid, name, email, password, create_at FROM users WHERE email = ?", email).Scan(&user.Id, &user.Uuid, &user.Name, &user.Email, &user.Password, &user.CreatedAt)
	return
}

// Get single user given the UUID
func UserByUUID(uuid string) (user User, err error) {
	user = User{}
	err = Db.QueryRow("SELECT id, uuid, name, email, password, create_at FROM users WHERE uuid = ?", uuid).Scan(&user.Id, &user.Uuid, &user.Name, &user.Email, &user.Password, &user.CreatedAt)
	return
}

创建 session.go 用于定义会话模型类 Session

package models

import "time"

// 用户自定义类型
type Session struct {
	Id       int
	Uuid     string
	Email    string
	UserId   int
	CreateAt time.Time
}

// 定义了基于 Db 数据库连接实例实现会话模型相关的增删改查操作
// Check if session is valid in the database
func (session *Session) Check() (vaild boot, err error) {
	err = Db.QueryRow("SELECT id, uuid, email, user_id, create_at FROM sessions WHERE uuid = ?", session.Uuid).Scan(&session.Id, &session.Uuid, &session.Email, &session.UserId, &session.CreatedAt)
	if err != nil {
		valid = false
		return
	}
	if session.Id != 0 {
		valid = true
	}
	return
}

// Delete session from database
func (session *Session) DeleteByUUID() (err error) {
	statement := "delete from sesions where uuid = ?"
	stmt, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	defer stmt.Close()

	_, err = stmt.Exec(session.Uuid)
	return
}

// Get the user from the session
func (session *Session) User() (user User, err error) {
	user = User{}
	err = Db.QueryRow("SELECT id, uuid, name, email, create_at FROM users WHERE id = ?", session.UserId).Scan(&user.id, &user.Uuid, &user.Name, &user.Email, &user.CreatedAt)
	return
}

// Delete all sessions from database
func SessionDeleteAll() (err error) {
	statement := "delete from sessions"
	_, err = Db.Exec(statement)
	return
}

主题相关模型类

编写好用户相关模型类后,接下来在同级目录下创建 thread.go,定义群组模型类 Thread 与 threads 表进行交互:

package models

import "time"

type Thread struct {
	Id       int
	Uuid     string
	Topic    string
	UserId   int
	CreateAt time.Time
}

// format the CreateAt date to display nicely on the screen
func (thread *Thread) CreateAtDate() string {
	return thread.CreatedAt.Format("Jan 2, 2006 at 3:04pm")
}

// get the number of posts in a thread
func (thread *Thread) NumReplies() (count int) {
	// Query执行查询操作,Query返回的结果集是sql.Rows类型。
	// 它有一个Next方法,可以迭代数据库的游标,进而获取每一行的数据
	rows, err := Db.Query("SELECT count(*) FROM posts where thread_id = ?", thread.Id)
	if err != nil {
		return
	}
	// 当我们通过for循环迭代数据库的时候,当迭代到最后一样数据的时候,会出发一个io.EOF的信号,引发一个错误,
	// 同时go会自动调用rows.Close方法释放连接,然后返回false,此时循环将会结束退出。
	// rows.Next循环迭代的时候,因为触发了io.EOF而退出循环。为了检查是否是迭代正常退出还是异常退出,需要检查rows.Err。
	for rows.Next() {
		if err = rows.Scan(&count); err != nil {
			return
		}
	}
	rows.Close()
	return
}

// get posts to thread
func (thread *Thread) Posts() (posts []Post, err error) {
	rows, err := Db.Query("SELECT id, uuid, body, user_id, thread_id, create_at, FROM posts where thread_id = ?", thread.Id)
	if err != nil {
		return
	}
	for rows.Next() {
		post := Post{}
		if err = rows.Scan(&post.Id, &post.Uuid, &post.Body, &post.UserId, &post.ThreadId, &post.CreatedAt); err != nil {
			return
		}
		posts = appent(posts, post)
	}
	rows.Close()
	return
}

// Get all threads in the database and returns it
func Threads() (thread []Thread, err error) {
	rows, err := Db.Query("SELECT id, uuid, topic, user_id, create_at FROM threads ORDER BY create_at DESC")
	if err != nil {
		return
	}
	for rows.Next() {
		conv := Thread{}
		if err = rows.Scan(&conv.Id, &conv.Uuid, &conv.Topic, &conv.UserId, &conv.CreatedAt); err != nil {
			return
		}
		threads = append(threads, conv)
	}
	rows.Close()
	return
}

// Get a thread by the UUID
func ThreadByUUID(uuid string) (conv Thread, err error) {
	conv = Thread{}
	err = Db.QueryRow("SELECT id, uuid, topic, user_id, created_at FROM threads WHERE uuid = ?", uuid).Scan(&conv.Id, &conv.Uuid, &conv.Topic, &conv.UserId, &conv.CreatedAt)
	return
}

// Get the user who started this thread
func (thread *Thread) User() (user User) {
	user = User{}
	Db.QueryRow("SELECT id, uuid, name, email, create_at FROM users WHERE id = ?", thread.UserId).Scan(&user.Id, &user.Uuid, &user.Name, &user.Email, &user.CreatedAt)
	return
}

以及 post.go 编写主题模型类与 posts 表进行交互:

package models

import "time"

type Post struct {
	Id       int
	Uuid     string
	Body     string
	UserId   int
	ThreadId int
	CreateAt time.Time
}

func (post *Post) CreateAtDate() string {
	return post.CreatedAt.Format("Jan 2, 2006 at 3:04pm")
}

// Get the user who wrote the post
func (post *Post) User() (user User) {
	user = User{}
	Db.QueryRow("SELECT id, uuid, name, email, created_at FROM users WHERE id = ?", post.UserId).Scan(&user.Id, &user.Uuid, &user.Name, &user.Email, &user.CreatedAt)
	return
}

此外,我们到 user.go 中为 User 模型新增如下两个方法与 ThreadPost 模型进行关联,用于创建新的群组和主题:

func (user *User) CreateThread(topic string) (conv Thread, err error) {
	statement := "insert into threads (uuid, topic, user_id, create_at) values (?, ?, ?, ?)"
	stmtin, err := Db.Prepare(statement)
	if err != nil {
        return
	}
	defer stmtin.Close()

	uuid := createUUID()
	stmtin.Exec(uuid, topic, user.Id, time.Now())

	stmtout, err := Db.Prepare("select id, uuid, topic, user_id, create_at from threads where uuid = ?")
	if err != nil {
        return
	}
	
	defer stmtout.Close()

	// user QueryRow to return a row and scan the returnde id into the Session struct
	err = stmtout.QueryRow(uuid).Scan(&conv.Id, &conv.Uuid, &conv.Topic, &conv.UserId, &conv.CreatedAt) 
	return
}

// Create a new post to a thread
func (user *User) CreatePost(conv Thread, body string) (post Post, err error) {
	// 声明插入数据
	statement := "insert into posts (uuid, body, user_id, thread_id, created_at) values (?, ?, ?, ?, ?)"
	stmtin, err := Db.Prepare(statement)
	if err != nil {
        return
	}
	defer stmtin.Close()

	uuid := createUUID()
	stmtin.Exec(uuid, body, user.Id, conv.Id, time.Now())

	stmtout, err := DbPrepare("select id, uuid, body, user_id, thread_id, create_at, from posts where uuid = ?")
	if err != nil {
        return
    }
	defer stmtout.Close()

	// user QueryRow to retrun a row and scan the returned id into the Session struct
	err = stmtout.QueryRow(uuid).Scan(&post.Id, &post.Uuid, &post.Body, &post.UserId, &post.ThreadId, &post.CreatedAt)
	return
}

小结

在上述编写的模型类中,模型类与数据表是如何映射的呢?这个由 go-mysql-driver 底层实现,每次从数据库查询到结果之后,可以通过 Scan 方法将数据表字段值映射到对应的结构体模型类,而将模型类保存到数据库时,又可以基于字段映射关系将结构体属性值转化为对应的数据表字段值。对应的底层交互逻辑如下所示:

模型类与数据库映射

底层数据库交互逻辑定义好了之后,接下来,我们就可以编写上层实现代码了,接下来我将给大家演示在线论坛项目上层路由和处理器方法的实现。

(三):访问论坛首页

整体流程

如何在服务端处理用户请求?

用户请求的处理流程如下:

  1. 客户端发送请求;
  2. 服务端路由器(multiplexer)将请求分发给指定处理器(handler);
  3. 处理器处理请求,完成对应的业务逻辑;
  4. 处理器调用模板引擎生成 HTML 并将响应返回给客户端。

接下来我们按照这个流程来编写服务端代码。

定义路由器

这里我们基于 gorilla/mux 来实现路由器,所以需要安装对应依赖:

go get github.com/gorilla/mux

将路由器定义在 routes 目录下的 router.go 中:

package routes

import "github/gorilla/mux"

// 定义路由器
// 返回一个mux.Router类型指针,从而可以当作处理器使用
func NewRouter() *mux.Router {
	// 创建mux.Router路由器示例
	router := mux.NewRouter().StrictSlach(true)

	// 遍历web.go 中定义的所有webRoutes
	for _, router := ranger webRoutes {
		// 将每个web路由应用到路由器
		router.Methods(route.Method).
			Path(router.Pattern).
			Name(route.Name).
			Handler(router.HandlerFunc)
	}

	return router
}

将所有路由定义在同一目录的 routes.go 中:

package routes

import "net/http"

// 定义一个 webRoute 结构体用于存放当个路由
type WebRoute struct {
	Name        string
	Method      string
	Pattern     string
	HandlerFunc http.HandlerFunc
}

// 声明 WebRoutes 切片存放所有 Web 路由
type WebRoutes []WebRoute

// 定义所有 Web 路由
var webRoutes = WebRoutes{
	
}

启动 HTTP 服务器

最后在项目根目录下的 main.go 中引入上述路由器来启动 HTTP 服务器:

package main

import (
	"net/http"

	. "github.com/xueyuanjun/chichat/routes"
)

func main() {
	startWebServer("8080")
}

// 通过指定端口启动 Web 服务器
func startWebServer(port string) {
	// 使用的路由器正是上述 router.go 中 NewRouter 方法返回的 mux.Router 指针类型实例
	r := NewRouter()
	http.Handler("/", r) // 通过 router.go 中定义的路由器来分发请求

	log.Println("Starting HTTP service at " + port)
	// 指定 HTTP 服务器监听 8080 端口
	err := http.ListenAndServe(":" + port, nil) // 启动协程监听请求

	if err := nil {
		log.Println("An error occured startung HTTP listener at port " + port)
		log.Println("Error: " + err.error())
	}
}

这里我们指定 HTTP 服务器监听 8080 端口,使用的路由器正是上述 router.go 中 NewRouter 方法返回的 mux.Router 指针类型实例,这里可以看到引用的时候并没有带上包名前缀,之所以可以这么做是因为通过如下这种方式引入的 routes 包:

. "github.com/xueyuanjun/chitchat/routes"

注意到前面的 . 别名,通过这种方式引入的包可以直接调用包中对外可见的变量、方法和结构体,而不需要加上包名前缀。

还有一种方式是通过 _ 别名引入,这样一来只会调用该包里定义的 init 方法,我们在前面引入 go-sql-driver/mysql 包时就是这么做的:

_ "github.com/go-sql-driver/mysql"

处理静态资源

在线论坛涉及到前端静态资源文件的处理,我们可以在 startWebServer 方法中新增如下这两行代码:

	r := NewRouter() // 通过 router.go 中定义的路由器来分发请求

	// 处理静态资源文件
	// http.FileServer 用于初始化文件服务器和目录为当前目录下的 public 目录。
	assets := http.FileServer(http.Dir("public"))
	// 指定静态资源路由及处理逻辑
	// 将 /static/ 前缀的 URL 请求去除 static 前缀
	// 然后在文件服务器查找指定文件路径是否存在(public 目录下的相对地址)
	r.PathPrefix("/static/").Handler(http.StriPrefix("/static/", assets))

    ...

比如 URL 请求路径为 http://localhost:8080/static/css/bootstrap.min.css,对应的查找路径是:

<application root>/public/css/bootstrap.min.css

对于静态资源文件直接返回文件内容,不会进行额外处理。

编写处理器实现

首页处理器方法

创建论坛首页的路由处理器,在 handlers 目录下新增一个 index.go 来定义首页的处理器方法:

package handlers

import (
	"net/http"
)

// 论坛首页路由处理器方法
func Index(w http.ResponseWriter, r *http.Request) {
	files := []string{"views/layout.html", "views/navbar.html", "views/index.html"}
	// 从文件来获得模板,文件通过ParseFiles函数
	// template.Must函数主要用于检测加载的模版有没有错误,有错误输出panic错误,并且结束程序。
	templates := template.Must(template.ParseFiles(files...))
    // 从数据库查询群组数据并将该数据传递到模板文件,最后将模板视图渲染出来
	threads, err := models.Threads();
	if err == nil {
		templates.executeTemplate(w, "layout", threads)
	}
}

创建视图模板

这里我们使用 Go 自带的 html/template 作为模板引擎,需要传入位于 views 目录下的视图模板文件,这里传入了多个模板文件,包括主布局文件 layout.html

{{ define "layout" }}

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=9">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>ChitChat</title>
        <link href="/static/css/bootstrap.min.css" rel="stylesheet">
        <link href="/static/css/font-awesome.min.css" rel="stylesheet">
    </head>
    <body>
    {{ template "navbar" .}}
    
    <div class="container">

        {{template "content" .}}

    </div> <!-- /container -->>   

    <script src="/static/js/jquery-2.1.1.min.js"></script>
    <script src="/static/js/bootstrap.min.js"></script>
    </body>           
    </html>
{{ end }}

顶部导航模板 navbar.html

{{ define "navbar" }}
    <div class="navbar navbar-default navbar-static-top" role="navigation">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a class="navbar-brand" href="/">
                    <i class="fa fa-comments-o"></i>
                    ChitChat
                </a>
            </div>
            <div claas="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a href="/">Home</a></li>
                </ul>
                <ul class="nav navbar-nav navbar-right">
                    <li><a href="/login">Login</a></li>
                </ul>
            </div>
        </div>
    </div>
{{ end }}

以及首页视图模板 index.html

{{ define "content" }}
    <p class="lead">
        <a href="/thread/new">Start a thread</a> or join one below!
    </p>

    {{ range .}}
        <dev class="panel panel-default">
            <div class="panel-heading">
                <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span> 
            </div>
            <div class="panel-body">
                Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }} posts.
                <div class="pull-right">
                    <a href="/thread/read?id={{.Uuid }}">Read more</a>
                </div>
            </div>
        </dev>
    {{ end }}
    
{{ end }}

引入多个视图模板是为了提高模板代码的复用性,对于同一个应用的不同页面来说,可能基本布局、页面顶部导航和页面底部组件都是一样的。

渲染视图模板

我们从数据库查询群组数据并将该数据传递到模板文件,最后将模板视图渲染出来,对应代码如下:

// 从数据库查询群组数据并将该数据传递到模板文件,最后将模板视图渲染出来
	threads, err := models.Threads();
	if err == nil {
		templates.executeTemplate(w, "layout", threads)
	}

编译多个视图模板时,默认以第一个模板名作为最终视图模板名,所以这里第二个参数传入的是 layout,第三个参数传入要渲染的数据 threads,对应的渲染逻辑位于 views/index.html 中:

    {{ range .}}
        <dev class="panel panel-default">
            <div class="panel-heading">
                <span class="lead"> <i class="fa fa-comment-o"></i> {{ .Topic }}</span> 
            </div>
            <div class="panel-body">
                Started by {{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }} posts.
                <div class="pull-right">
                    <a href="/thread/read?id={{.Uuid }}">Read more</a>
                </div>
            </div>
        </dev>
    {{ end }}

其中 {{ range . }} 表示将处理器方法传入的变量 threads 进行循环。

注册首页路由

我们在 routes/routes.go 中注册首页路由及对应的处理器方法 Index

import "github.com/xueyuanjun/chitchat/handlers"

// 定义所有 Web 路由
var webRoutes = WebRoutes{
	// 注册首页路由及对应的处理器方法 Index
	{
		"home",
		"GET",
		"/",
		handlers.Index
	},
}

访问论坛首页

访问论坛首页之前,我们将相应的前端资源文件拷贝到 public 目录下,此时项目整体目录结构如下:

> 注:对应的前端资源可以从项目的 Github 仓库获取:https://github.com/nonfu/chitchat.git

然后我们在项目根目录下运行如下代码启动 HTTP 服务器:

go run main.go

然后我们在浏览器访问论坛首页 http://localhost:8080

下篇教程,我们将基于 Cookie + Session 实现用户认证并创建群组和主题。

(四):通过 Cookie + Session 实现用户认证

我们需要在网站页面上实现群组、主题的增改改查,不过,我们的需求是用户认证之后才能执行这些操作,所以需要先实现用户认证相关功能。

编写全局辅助函数

在此之前,我们现在 handlers 目录下创建一个 helper.go 文件,用于定义一些全局辅助函数(主要用在处理器中):

package handlers

import (
	"errors"
	"fmt"
	"html/template"
	"net/http"

	"github.com/xueyuanjun/chitchat/models"
)

// 通过 Cookie 判断用户是否已登录
func session(writer http.ResponseWriter, request *http.Request) (sess models.Session, err error) {
	cookie, err := request.Cookie("_cookie")
	if err == nil {
		sess = models.Session(Uuid: cookie.Value)
		if ok, _ := sess.Check(); !ok {
			err = errors.New("Invalid session")
		}
	}
	return
}

// 解析 HTML 模板(应对需要传入对个模板文件的情况,避免重复编写模板代码)
func parseTemplateFiles(filenames ...string) (t *template.Template) {
	var files []string
	t = template.New("layout")
	for _, file := range filenames {
		files = append(files, fmt.Sprintf("views/%s.html", file))
	}
	t = template.Must(t.ParseFiles(files...))
	return
}

// 生成响应 HTML
func generateHTML(write http.ResponseWriter, data interface{}, filenames ...string) {
	var files []string
	for _, file := range filenames {
		files = append(files, fmt.Sprintf("views/%s.html", file))
	}

	template = template.Must(template.ParseFiles(files...))
	template.ExecuteTemplate(writer, "layout", data)
}

// 返回版本号
func Version() string {
	return "0.1"
}

目前提供了版本信息,判断用户是否登录,HTML 模板的解析与生成等逻辑,我们将 HTML 模板解析与生成逻辑提取出来,主要是为了避免重复编写类似的模板代码,比如现在,我们可以将 handlers/index.go 中的 Index 方法改写如下:

func Index(w http.ResponseWriter, r *http.Request) {
    threads, err := models.Threads();
    if err == nil {
		// templates.executeTemplate(w, "layout", threads)
		generateHTML(w, threads, "layout", "navbar", "index")
	}
}

看起来简单多了,更重要的是提高了代码的复用性。

在 session 函数中,通过从请求中获取指定 Cookie 字段里面存放的 Session ID,然后从 Session 存储器(这里存储驱动是数据库)查询对应 Session 是否存在来判断用户是否已认证,如果已认证则返回的 sess 不为空。

用户认证相关处理器

编写处理器代码

接下来,在 handlers 目录下创建一个 auth.go 来存放用户认证相关处理器:

package handlers

import (
	"fmt"
	"net/http"
)

// GET /login
// 登陆页面
func Login(write http.ResponseWriter, request *http.Request) {
	t := parseTemplateFiles("auth.layout", "navbar", "login")
	t.Execute(writer, nil)
}

// GET /signup
// 注册页面
func Signup(writer http.ResponseWriter, request *http.Request) {
	generateHTML(writer, nil, "auth.layout", "navbar", "signup")
}

// POST /signup
// 注册新用户
func SignupAccount(writer http.ResponseWriter, request *http.Request) {
	err := request.ParseForm()
	if err != nil {
		fmt.Println("Cannot parse form")
	}
	user := models.User{
		Name:     request.PostFormValue("name"),
		Email:     request.PostFormValue("email"),
		Password:     request.PostFormValue("password"),
	}
	if err := user.Create(); err !=nil {
		fmt.Println("Cannot create user")
	}
	http.Redirect(writer, request, "/login", 302)
}

// POST /authenticate
// 通过邮箱和密码字段对用户进行认证
func Authenticate(writer http.ResponseWriter, request *http.Request) {
	err := request.ParseForm()
	user, err := models.UserByEmail(requst.PostFormValue("email"))
	if err != nil {
		fmt.Println("Cannot find user")
	}
	if user.Paasword == models.Encrypt(request.PostFormValue("password")) {
		session, err := user.CreateSession()
		if err != nil {
			fmt.Println("Cannot create session")
		}
		cookie := http.Cookie{
			Name:     "_cookie",
			value:    session.Uuid,
			HttpOnly: true,
		}
		http.SetCookie(writer, &cookie)
		http.Redirect(writer, request, "/, 302)
	} else {
		http.Redirect(writer, request, "/login", 302)
	}
}

// GET /logout
// 用户退出
func Logout(writer http.ResponseWriter, request *http.request) {
	cookie, err := request.Cookie("_cookie")
	if err != http.ErrNoCookie {
		fmt.Println("Failed to get cookie")
		session := models.Session{Uuid: cookie.Value}
		session.DeleteByUuid()
	}
	http.Redirect(writer, request, "/", 302)
}

上述代码中定义了用户注册、登录、退出相关业务逻辑。

用户注册

用户注册逻辑比较简单,无非是填写注册表单(Signup 处理器方法),提交注册按钮将用户信息保存到数据库(SignupAccount 处理器方法)。

用户登录

接下来,服务端会将用户重定向到登录页面(Login 处理器方法),用户填写登录表单后,就可以通过 Authenticate 处理器方法执行认证操作。

用户认证是基于 Cookie + Session 实现的,Session 的数据结构如下所示:

type Session struct {
	Id        int
	Uuid      string
	Email     string
	UserId    int
	CreatedAt time.Time
}

通过 Uuid 字段可以唯一标识这个 Session,因此可以看作是对外可见的全局 Session ID,在客户端 Cookie 存储的 Session ID 也是这个 Uuid。当用户认证成功之后,就会创建 Session,有了 Session 之后,就可以创建 Cookie 并写到响应中:

		cookie := http.Cookie{
			Name:     "_cookie",
			value:    session.Uuid,
			HttpOnly: true,
		}
		http.SetCookie(writer, &cookie)

这样,下次用户访问在线论坛页面就会在请求头中带上包含 Session ID 的 Cookie,服务端通过解析这个 Uuid 并查询 Session 存储器(这里存储驱动是数据库)判断该用户 Session 是否存在,如果存在则用户认证通过,也就是前面辅助函数 session 所做的事情。

用户退出

上述 Cookie 未设置过期时间,所以生命周期和 Session 一致,当浏览器关闭时,Cookie 就自动删除,下次打开浏览器需要重新认证。

最后用户退出处理器方法 Logout 方法则是方便用户主动退出,当用户点击退出按钮,可以执行该处理器方法销毁当前用户 Session 和认证 Cookie,并将用户重定向到首页。

用户认证相关视图模板

定义好认证处理器后,我们来编写与认证相关的视图模板,主要是登录页面和注册页面,在 views 目录下新增 login.html 编写登录页面:

{{ define "content"}}

<form class="form-signin center" role="form" action="/authenticate" method="post">
  <h2 class="form-signin-heading">
      <i class="fa fa-comments-o">
        ChitChat
      </i>
  </h2>
  <input type="email" name="email" class="form-control" placehoder="Email address" required autofocus>
  <input type="password" name="password" class="form-control" placeholder="Password" required>
  <br/>
  <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button> 
  <br/>
  <a class="lead pull-right" href="/signup">Sign up</a>
</form>

{{ end }}

然后创建 signup.html 编写注册页面:

{{ define "content"}}

<form class="form-signin center" role="form" action="/authenticate" method="post">
  <h2 class="form-signin-heading">
      <i class="fa fa-comments-o">
        ChitChat
      </i>
  </h2>
  <div class="lead">Sign up for an account below</div>
  <input type="name" type="text"name="name" class="form-control" placehoder="Name" required autofocus>
  <input type="email" name="email" class="form-control" placehoder="Email address" required>
  <input type="password" name="password" class="form-control" placeholder="Password" required>
  <button class="btn btn-lg btn-primary btn-block" type="submit">Sign up</button> 
</form>

{{ end }}

此外,我们还为登录和注册页面定义了单独的布局模板 auth.layout.html

{{ define "layout"}}

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=9">
        <meta name="viewport" contet="width=device-width, initial-scale=1">
        <title>ChitChat</title>
        <link href="/static/css/bootstrap.min.css" rel="stylesheet">
        <link href="/static/css/font-awesome.min.css" rel="stylesheet">
        <link href="/static/css/login.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="container">
          
          {{ template "content" . }}

        </div> <!-- /container-->

        <script src="/static/js/jquery-2.1.1.min.js"></script>
        <script src="/static/js/bootstrap.min.js"></script>
    </body>
</html>

{{ end}}

以上视图模板已经在认证处理器方法中引用。

注册用户认证路由

最后,我们需要在 routes/routes.go 中注册用户认证相关路由:

// 定义所有 Web 路由
var webRoutes = WebRoutes{
    ... // 其他路由
    {
        "signup",
        "GET",
        "/signup",
        handlers.Signup,
    },
    {
        "signupAccount",
        "POST",
        "/signup_account",
        handlers.SignupAccount,
    },
    {
        "login",
        "GET",
        "/login",
        handlers.Login,
    },
    {
        "auth",
        "POST",
        "/authenticate",
        handlers.Authenticate,
    },
    {
        "logout",
        "GET",
        "/logout",
        handlers.Logout,
    },
}

测试用户认证功能

这样一来,我们就可以重启应用并访问用户注册页面 http://localhost:8080/signup 进行注册了:

注册成功后,页面会跳转到登录页面 http://localhost:8080/login

输入刚才填写的注册邮箱和密码,点击「SIGN IN」按钮登录成功后,页面跳转到首页。

我们还没有对首页做额外的认证判断和处理,所以此时显示的页面效果和之前一样,为了区别用户认证与未认证状态,我们可以基于认证状态渲染不同的导航模板,对于认证用户,渲染 auth.navbar 模板,对于未认证用户,还是保持和之前一样,为此,我们需要在 views 目录下新增 auth.navbar.html 视图:

{{ define "navbar" }}
<div class="navbar navbar-default navbar-static-top" role="navigation">
  <div class="container">
    <div class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
        <span class="sr-only">Toggle navigation</span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="/">
        <i class="fa fa-comments-o"></i>
        ChitChat
      </a>
    </div>
    <div class="navbar-collapse collapse">
      <ul class="nav navbar-nav">
        <li><a href="/">Home</a></li>
      </ul>
      <ul class="nav navbar-nav navbar-right">
        <li><a href="/logout">Logout</a></li>
      </ul>
    </div>
  </div>
</div>
{{ end }}

同时还要修改 handlers.Index 处理器方法实现:

func Index(writer http.ResponseWriter, request *http.Request) {
    threads, err := models.Threads();
    if err == nil {
        _, err := session(writer, request)
        if err != nil {
            // templates.executeTemplate(w, "layout", threads)
            generateHTML(writer, threads, "layout", "navbar", "index")
        } else {
            generateHTML(writer, threads, "layout", "auth.navbar", "index")
        }
    }
}

再次重启应用,刷新首页,导航条的展示效果就不一样了:

此时显示的是「Logout」链接,点击即可退出应用:

(五):创建群组和主题功能实现

如何创建群组和主题,并将其渲染到前端页面。

群组的创建和浏览

处理器方法

在 handlers 目录下新增 thread.go 编写群组创建与获取方法:

package handlers

import (
	"fmt"
	"github.com/xueyuanjun/chitchat/models"
	"net/http"
)

// GET /hreads/new
// 创建群组页面
func NewThread(writer http.ResponseWriter, request *http.Request) {
	_, err := session(writer, request)
	if err != nil {
		http.Redirect(writer, request, "/login", 302)
	} else {
		generateHTML(writer, nil, "layout", "auth.navbar", "new.thread")
	}
}

// POST /thread/create
// 执行群组创建逻辑
func CreateThread(writer http.ResponseWriter, request *hhtp.Request) {
	sess, err := session(writer, request)
	if err != nil {
		http.Redirect(writer, requestt, "/login", 302)
	} else {
		err = request.ParseForm()
		if err != nil {
			fmt.Println("Cannot parse form")
		}
		user, err := sess.User()
		if err != nil {
			fmt.Println("Cannot get user from session")
		}
		topic := request.PostFormValue("topic")
		if _, err := user.CreateThread(topic); err != nil {
			fmt.Println("Cannot create thread")
		}
		http.Redirect(writer, request, "/", 302)
	}
}

// http.ResponseWriter 是一个接口,通过引用;而 http.Request 是一个 struct,传递指针以避免复制数据。
// GET /thread/read
// 通过 ID 渲染指定群组页面
func ReadThread(writer http.ResponseWriter, request *http.request) {
	vals := request.URL.Query()
	uuid := vals.Get("id")
	thread, err := models.ThreadByUUID(uuid)
	if err != nil {
		fmt.Println("Cannot read thread")
	} else {
		_, err := session(writer, request)
		if err != nil {
			generateHTML(writer, &thread, "layout", "navbar", "thread")
		} else {
			generateHTML(writer, &thread, "layout", "auth.navbar", "auth.thread")
		}
	}	
}

其中定义了三个方法,分别用于渲染群组创建表单页面、处理提交表单执行群组创建逻辑、以及根据指定 ID 渲染对应群组页面。前两个方法需要认证后才能访问,否则将用户重定向到登录页,群组详情页不需要认证即可访问,不过会根据是否认证返回不同的视图模板。

在这里,仍然通过辅助函数 session 判断用户是否认证,其他的业务逻辑也都非常简单,无非是获取表单输入、查询数据库、写入数据库、返回响应视图等操作,后面我们会在介绍处理 HTTP 请求时详细解释其中的细节,这里,我们先了解下全貌即可。

视图模板

然后我们需要创建几个新的视图模板,在 views 目录下 new.thread.html 来编写创建群组表单:

{{ define "content" }}

    <form role="form" action="/thread/create" method="post">
        <div class="lead">Start a new thread with the following topic</div>
        <div class="form-group">
            <textarea class="form-control" name="topic" id="topic" palceholder="Thread topic here" rows="4"></textarea>
            <br/>
            <br/>
            <button class="btn btn-lg btn-primary pull-right" type="submit">Start this thread</button>
        </div>
    </form>
    
{{ end }}

然后创建 thread.html 编写未认证情况下渲染的群组详情页视图(其中还包含了对群组主题的遍历和渲染):

{{ define "content" }}

<div class="panel panel-default">
    <div class="panel-heading">
        <span class="lead"> <i class="fa fa-comment-o"></i> {{.Topic}}</span>
        <div class="pull-right">
          Started by {{ .User.Name}} - {{ .CreatedAtDate}} 
        </div> 

    </div>

    {{ range .Posts}}
    <div class="panel-body">
      <span class="lead"> <i class="fa fa-comment"></i>{{ .Body }}</span>
      <div class="pull-right">
        {{ .User.Name }} - {{ .CreateAtDate}} 
      </div>  
    </div>
    {{ end }}
</div>

{{ end }}

以及 auth.thread.html 编写认证后的群组详情页视图(在未认证视图模板的基础上新增了提交主题的表单区块):

{{ define "content" }}

<div class="panel panel-default">
    <div class="panel-heading">
        <span class="lead"> <i class="fa fa-comment-o"></i> {{.Topic}}</span>
        <div class="pull-right">
            Started by {{ .User.Name}} - {{ .CreatedAtDate}} 
        </div> 

    </div>

    {{ range .Posts}}
    <div class="panel-body">
      <span class="lead"> <i class="fa fa-comment"></i>{{ .Body }}</span>
      <div class="pull-right">
        {{ .User.Name }} - {{ .CreateAtDate}} 
      </div>  
    </div>
    {{ end }}

</div>

<div class="panel panel-info">
    <div class="panel-body">
        <form role="form" action="/thread/post" method="post">
            <div class="form-group">
                <textarea class="form-control" name="body" placeholder="Write your reply here" rows="3"></textarea>
                <input type="hidden" name="uuid" value="{{ .Uuid }}">
                <br/>
                <button class="btn btn-primary pull-right" type="submit">Reply</button>
            </div>
        </form>
    </div>
</div>

{{ end }}

注册路由

最后在 routes/routes.go 中注册群组相关路由:

var webRoutes = WebRoutes{
    ... // 其他路由
    {
        "newThread",
        "GET",
        "/thread/new",
        handlers.NewThread,
    },
    {
        "createThread",
        "POST",
        "/thread/create",
        handlers.CreateThread,
    },
    {
        "readThread",
        "GET",
        "/thread/read",
        handlers.ReadThread,
    },
}

测试群组创建和浏览

这样,我们就完成了在线论坛项目群组创建和浏览的所有相关路由、处理器、视图编码,重新启动 HTTP 服务器,就可以在首页点击「Start a thread」链接创建新的群组了:

如果没有登录,会先跳转到登录页面,登录之后再次点击该链接就可以进入群组创建页面:

我们在输入框中输入群组主题「Golang」并点击右下角提交按钮,就可以成功创建一个新的群组并在首页看到了:

然后,我们可以点击该群组的「Read more」链接进入群组详情页:

目前还没有任何主题,接下来,我们来编写创建主题的后端处理器方法和路由实现。

创建新主题

处理器方法

我们在 handlers 目录下新增 post.go 来存放主题相关处理器方法:

package handlers

import (
	"fmt"
	"net/http"
)

// POST /thread/post
// 指定群组下创建新主题
func PostThread(writer http.ResponseWriter, request *http.Request) {
	sess, err := session(writer, request)
	if err != nil {
		http.Redirect(writer, request, "/login", 302)
	} else {
		err = request.ParseForm()
		if err != nil {
			fmt.Println("Cannot parse form")
		}
		user, err := sess.User()
		if err != nil {
			fmt.Println("Cannot get user form session")
		}
		body := request.PostFormValue("body")
		uuid := request.PostFormValue("uuid")
		thread, err := models.ThreadByUuid(uuid)
		if err != nil {
			fmt.Println("Cannot read thread")
		}
		if _, err := user.CreatePost(thread, body); err != nil {
			fmt.Println("Cannot create post")
		}
		// The real difference between Printf and Sprintf is
		// Sprintf formats the string and does not print to console. It returns the formatted string.
		// Printf formats the string and prints in the console. It does not return the formatted string.
		url := fmt.Sprint("/thread/read?id=", uuid)
		http.Redirect(writer, request, url, 302)
	}
}

我们只定义了一个创建主题的处理器方法,在该处理器方法中,仍然会验证用户是否已认证,只有认证用户才能创建主题,我们最后会调用 user.CreatePost 方法根据群组 ID、用户 ID 和主题内容创建新的主题记录,保存成功后,会返回创建该主题的群组详情页,并将与该群组关联的所有主题渲染出来。关于数据库和视图模板引擎的语法细节,后面我们会在对应的独立教程中详细介绍。

注册路由

由于主题没有独立的视图模板,所以我们只需要在路由文件中注册创建主题对应的路由就可以了:

{
    "postThread",
    "POST",
    "/thread/post",
    handlers.PostThread,
},

测试主题创建

再次重启 HTTP 服务器,就可以在之前的群组详情页创建新主题了:

点击「REPLY」按钮提交,页面会刷新并渲染主题内容:

回到论坛首页,你可以看到每个群组下的主题数目:

关于在线论坛项目的基本功能我们就演示到这里,相信你已经对 Go 语言 Web 编程中的 MVC 模式有了初步的了解,下篇教程,我们来探讨下如何对项目中的日志和错误处理做统一的管理。

(六):日志和错误处理

到目前为止,所有的日志和错误处理都是杂糅在业务代码中,能不能统一进行处理,使得业务代码和日志及错误处理逻辑分离呢?

当然可以,在这个简单的项目中,我们通过辅助函数来处理日志和错误。

日志处理

初始化日志处理器

首先来看日志处理,在 handlers/helper.go 中,新增如下日志处理器初始化代码:

import (
    "log"
    "os"
)

var logger *log.Logger

func init()  {
    file, err := os.OpenFile("logs/chitchat.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
        log.Fatalln("Failed to open log file", err)
    }
    logger = log.New(file, "INFO ", log.Ldate|log.Ltime|log.Lshortfile)
}

这里我们借助 Go 官方提供的 log 包进行日志处理,首先声明一个 *log.Logger 类型的 logger 变量作为日志处理器,以便可以全局使用。默认的日志文件位于 logs/chitchat.log,我们通过 os.OpenFile 打开这个日志文件句柄,如果文件不存在,则自动创建。然后我们通过 log.New 初始化日志处理器并赋值给 logger,该方法需要传入日志文件、默认日志级别、以及日志格式,关于该方法的细节,我们后面在日志章节会详细介绍。

定义日志函数

然后我们就可以通过 logger 这个日志处理器来记录日志了,在 helper.go 中新增如下几个日志函数:

func info(args ...interface{}) {
	logger.SetPrefix("INFO")
	logger.Println(args...)
}

// 为什么不命名 error? 避免和 error 类别重名
func danger(args ...interface{}) {
	logger.SetPrefix("ERROR")
	logger.Println(args...)
}

func warning(args ...interface{}) {
	logger.SetPrefix("WARNING")
	logger.Println(args...)
}

非常简单,我们定义了三个日志函数来记录三个日志级别,分别是 INFO(普通)、ERROR(错误)、WARNING(警告),然后通过调用 logger.Println 传入参数记录日志信息到日志文件即可,这里的参数类型是 ...interface{},表示可以传入参数支持任意类型、任意个数。

重构业务代码

接下来,我们到业务处理器中,将原来的日志打印代码都重构为调用对应的日志函数,以 handlers/auth.go 为例,修改日志处理代码如下:

// src/github.com/xueyuanjun/chitchat/handlers/auth.go
// 注册新用户
func SignupAccount(writer http.ResponseWriter, request *http.Request) {
    err := request.ParseForm()
    if err != nil {
        danger(err, "Cannot parse form")
    }
    user := models.User{
        Name:     request.PostFormValue("name"),
        Email:    request.PostFormValue("email"),
        Password: request.PostFormValue("password"),
    }
    if err := user.Create(); err != nil {
        danger(err, "Cannot create user")
    }
    http.Redirect(writer, request, "/login", 302)
}

// 用户认证
func Authenticate(writer http.ResponseWriter, request *http.Request) {
    err := request.ParseForm()
    user, err := models.UserByEmail(request.PostFormValue("email"))
    if err != nil {
        danger(err, "Cannot find user")
    }
    if user.Password == models.Encrypt(request.PostFormValue("password")) {
        session, err := user.CreateSession()
        if err != nil {
            danger(err, "Cannot create session")
        }
        cookie := http.Cookie{
            Name:     "_cookie",
            Value:    session.Uuid,
            HttpOnly: true,
        }
        http.SetCookie(writer, &cookie)
        http.Redirect(writer, request, "/", 302)
    } else {
        http.Redirect(writer, request, "/login", 302)
    }
}

// 用户退出
func Logout(writer http.ResponseWriter, request *http.Request) {
    cookie, err := request.Cookie("_cookie")
    if err != http.ErrNoCookie {
        warning(err, "Failed to get cookie")
        session := models.Session{Uuid: cookie.Value}
        session.DeleteByUUID()
    }
    http.Redirect(writer, request, "/", 302)
}

其他处理器方法参照这个示例进行调整即可,你也可以在 Github 上参照本项目源码进行修改:https://github.com/nonfu/chitchat

错误处理

Go 语言并没有像 PHP、Java 那样提供异常这种类型,只有 error 和 panic,对于 Go Web 应用中的错误处理,不影响程序继续往后执行的,可以通过日志方式记录下来,如果某些错误导致程序无法往后执行,比如浏览群组详情页,对应群组不存在,这个时候,我们就应该直接返回 404 响应或者将用户重定向到 404 页面,而不能继续往后执行,对于这种错误,只能通过单独的处理逻辑进行处理,这种错误类似于 Laravel 中的中断异常处理。

重定向到错误页面

在这个项目中,我们通过重定向到错误页面的方式处理这种类型的错误,在 handlers/helper.go 中新增 error_message 函数:

// 异常处理统一重定向到错误页面
func error_message(writer http.ResponseWriter, request *http.Request, msg string) {
    url := []string{"/err?msg=", msg}
    http.Redirect(writer, request, strings.Join(url, ""), 302)
}

调用该方法会将用户重定向到错误处理页面(由 err 路由对应处理器方法渲染),响应状态码为 302,并且带上错误消息 msg,以便客户端感知错误原因。

编写错误页面相关代码

为此,我们还要编写用于处理应用出错的路由、处理器和视图实现。

处理器方法

首先在 handlers/index.go 中编写全局的、渲染错误页面的处理器方法:

// 编写全局的、渲染错误页面的处理器方法
func Err(writer http.ResponseWriter, request *http.Request) {
	vals := request.URL.Query()
	_, err := session(writer, request)
	if err != nil {
		generateHTML(writer, vals.Get("msg"), "layout", "navbar", "error")
	} else {
		generateHTML(writer, vals.Get("msg"), "layout", "auth.navbar", "error")
	}
}

我们可以通过 vals.Get 方法从查询字符串获取 msg 参数,并将其渲染到错误视图 error.html 中。

错误视图

然后在 views 目录下新增 error.html 用来定义错误视图:

{{ define "content" }}
    
<p class="lead">{{ . }}</p>
    
{{ end }}

非常简单,只是通过 {{ . }} 获取 msg 变量的值并渲染出来。

注册路由

最后在 routes/routes.go 中注册错误路由:

{
    "error",
    "GET",
    "/err",
    handlers.Err,
},

重构业务代码

在必要的地方调用错误处理函数 error_message 将用户重定向到错误页面,比如在 handlers/thread.go 中,在浏览群组详情页时,如果指定 ID 对应群组不存在,则将用户重定向到错误页面:

// 通过 ID 渲染指定群组页面
func ReadThread(writer http.ResponseWriter, request *http.Request) {
    vals := request.URL.Query()
    uuid := vals.Get("id")
    thread, err := models.ThreadByUUID(uuid)
    if err != nil {
        error_message(writer, request, "Cannot read thread")
    } else {
        ...
    }
}

又比如 handlers/post.go 中,在创建新主题时,如果获取不到主题归属的群组,则将用户重定向到错误页面:

// 在指定群组下创建新主题
func PostThread(writer http.ResponseWriter, request *http.Request) {
    sess, err := session(writer, request)
    if err != nil {
        http.Redirect(writer, request, "/login", 302)
    } else {
        ... 
        thread, err := models.ThreadByUUID(uuid)
        if err != nil {
            error_message(writer, request, "Cannot read thread")
        }
        ...
    }
}

整体测试

至此,我们已经完成了日志和错误统一处理的代码重构,接下来,可以进行简单的测试,重启 HTTP 服务器,访问应用首页,此时会引入 helper.go,执行 init 方法,创建日志文件,我们试图使用错误的用户名密码登录:

测试就可以在 logs/chitchat.log 中看到错误日志了:

ERROR 2020/04/07 14:55:39 helper.go:71: sql: no rows in result set Cannot find user

接下来,我们访问一个不存在的群组 http://localhost:8080/thread/read?id=100,页面就会重定向到错误页面:

关于日志和错误处理,我们就简单介绍到这里,下篇教程,将给大家演示如何通过配置文件对敏感信息和可变信息进行配置,然后从配置文件读取这些信息。

(七):通过单例模式获取全局配置

为什么使用配置

在实际项目开发中,我们通常会将一些敏感信息或者可变信息通过配置文件进行配置,然后在应用中读取这些配置文件来获取配置信息。

将敏感信息通过配置文件读取是为了避免随着代码提交到公开库造成敏感信息的泄漏,给线上环境带来安全隐患,这些敏感信息包括数据库连接信息、第三方 SDK(比如微信、支付宝、Github)的密钥等。

将可变信息通过配置文件读取是为了避免硬编码,将经常变动的信息通过配置文件配置可以极大提高代码的可维护性,这些可变信息通常包括应用服务器监听的地址和端口号、目录路径设置、当前运行环境、超时时间等。

定义全局配置文件

接下来,我们为在线论坛这个简单的项目设置配置文件 config.json,将一些敏感信息和可变信息提取到 JSON 配置文件中来:

{
  "App": {
    "Address": "0.0.0.0:8080",
    "Static": "public",
    "Log": "logs"
  },
  "Db": {
    "Driver": "mysql",
    "Address": "localhost:3306",
    "Database": "chitchat",
    "User": "root",
    "Password": "root"
  }  
}

我们将应用相关的可变信息配置到 app 配置项,将数据库相关的敏感信息配置到 db 配置项。

注:为了保证配置文件不提交到公开仓库造成安全泄漏,不要把 config.json 文件提交到代码仓库。

通过单例模式初始化全局配置

在根目录下创建 config 目录,然后在该目录下新增 config.go 用来存放配置初始化代码:

package config

import (
	"encoding/json"
	"log"
	"os"
	"sync"
)

type App struct {
	Address string
	Static  string
	Log     string
}

type Database struct {
	Driver   string
	Address  string
	Database string
	User     string
	Password string
}

type Configuration struct {
	App App
	Db  Database
}

var config *Configuration

// sync.once可以控制函数只能被调用一次,不能多次重复调用。
// sync.Once 实现单例模式
//  sync.Once只有一个Do方法,可以保证这个函数f只执行一次。
var once sync.Once

// 通过单例模式初始化全局配置
func LoadConfig() *Configuration {
	once.Do(func() {
		file, err := os.Open("config.json")
		if err != nil {
			log.Fatalln("Cannot open config file", err)
		}
		decoder := json.NewDecoder(file)
		config = &Configuration{}
		err = decoder.Decode(config)
		if err != nil {
			log.Fatalln("Cannot get configuration from file", err)
		}
	})
	return config
}

我们定义了 Configuration 结构体以便和全局配置文件 config.json 字段进行映射,注意这里的首字母都需要大写。

然后我们定义了一个 LoadConfig 方法以单例模式返回全局配置实例的指针,这里使用单例的原因是因为应用代码中可能多处都要获取配置值,重复加载配置文件进行 JSON 解码存在性能损耗(当然,定义 init 方法本身就可以支持全局运行一次,这里主要演示下单例模式如何实现)。在 Go 语言中,我们可以借助之前在并发编程中提到的 sync.Once 类型来实现单例模式,保证并发安全,在 once.Do 中定义的匿名函数全局只会执行一次。

项目代码重构

最后,我们将项目代码中相应位置的硬编码调整为通过上面方法返回的全局配置实例获取配置值。

Web 服务器启动参数

首先需要在 main.go 的入口位置初始化全局配置:

package main

import (
	"net/http"
    . "github.com/xueyuanjun/chitchat/config"
	. "github.com/xueyuanjun/chichat/routes"
)

func main() {
	startWebServer("8080")
}

// 通过指定端口启动 Web 服务器
func startWebServer(port string) {
	// 在入口位置初始化全局配置
	config := LoadConfig()
	// 使用的路由器正是上述 router.go 中 NewRouter 方法返回的 mux.Router 指针类型实例
	r := NewRouter() // 通过 router.go 中定义的路由器来分发请求

	// 处理静态资源文件
	// http.FileServer 用于初始化文件服务器和目录为当前目录下的 public 目录。
	assets := http.FileServer(http.Dir("public"))
	// 指定静态资源路由及处理逻辑
	// 将 /static/ 前缀的 URL 请求去除 static 前缀
	// 然后在文件服务器查找指定文件路径是否存在(public 目录下的相对地址)
	r.PathPrefix("/static/").Handler(http.StriPrefix("/static/", assets))

	http.Handler("/", r) // 应用路由器到 HTTP 服务器
    
	log.Println("Starting HTTP service at " + port)
	// 指定 HTTP 服务器监听 8080 端口
	err := http.ListenAndServe(":" + port, nil) // 启动协程监听请求

	if err := nil {
		log.Println("An error occured startung HTTP listener at port " + port)
		log.Println("Error: " + err.error())
	}
}

我们在 startWebServer 方法的入口位置初始化全局配置,并且全局配置实例只在这里进行一次初始化,后续不会再执行加载配置文件和 JSON 解码操作,而是直接返回对应的 config 实例:

config := LoadConfig()

然后将 Web 服务器的启动参数和静态资源目录都调整为通过配置值获取,这样我们后续只需要更改配置文件即可对其进行调整,而不需要修改任何代码,降低了代码维护成本。

数据库连接配置

接下来,打开 models/db.go,将数据库连接信息调整为通过配置文件读取:

package models

import (
	"crypto/rand"
	"crypto/sha1"
	"database/sql"
	"fmt"
	"log"

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

// Db 变量代表数据库连接池
// 该变量首字母大写,方便在 models 包之外被引用
// 具体的操作实现我们放到独立的模型文件中处理
var Db *sql.DB

// 数据库连接初始化方法
// 通过 init 方法在 Web 应用启动时自动初始化数据库连接
// 在应用中通过 Db 变量对数据库进行增删改查操作
func init() {
	var err error
	config := LoadConfig() // 加载全局配置实例
	driver := config.Db.driver
	source := fmt.Sprintf("%s:%s@(%s)/%s?chartset=utf8&parseTime=true"), config.Db.User, config.Db.Password, config.Db.Address, config.Db.Database)
	// 通过 sql.Open 初始化数据库连接,我们写死了数据库连接配置,
	// 在实际生产环境,这块配置值应该从配置文件或系统环境变量获取。
	// Db, err = sql.Open("mysql", "root@1234/chitchat?chartset=utf8&parseTime=true")
	Db, err = sql.Open(driver, source)
	if err != nil {
		log.Fatal(err)
	}
	return
}

...

虽然,在这里页调用了 LoadConfig(),但是由于是单例模式,所以会直接返回 config 实例,不会再进行初始化操作,然后我们获取配置值填充对应的 sql.Open 连接配置。

整体测试

至此,我们已经完成了通过配置文件读取应用配置的代码重构,我们可以为项目编写单元测试,也可以直接通过在浏览器访问这个在线论坛项目验证重构后应用是否可以正常运行,重新启动 Web 服务器,输出如下:

表示启动服务器时读取配置信息正常,然后访问应用首页:

由于本项目是基于 sausheong/gwp/chitchat 进行的二次开发,前端视图没有做任何调整,文案都是英文的,所以穿插一篇本地化教程,介绍如何对 Go Web 应用进行本地化编程。

(八):消息、视图和日期时间本地化

我们接着上篇在线论坛的进度,由于之前所有页面和消息文本都是英文的,而我们开发的应用基本都是面向中文用户的,所以需要对项目进行本地化,今天正好借着这个入门项目给大家介绍下如何在 Go Web 应用中进行国际化和本地化编程,由于项目比较简单,我们只介绍消息提示、视图模板和日期格式的本地化,更多本地化实现留待后面本地化专题详细介绍。

消息本地化

安装 go-i18n 扩展包

首先来看消息提示文本,消息提示文本通常包括表单验证消息、应用异常消息、接口响应消息等后端接口返回的消息字符串片段,关于这一块的本地化,可以借助 Go 官方自带的 golang.org/x/text 扩展包实现,这个扩展包扩展性好,但是上手起来有点复杂,所以今天使用的是一款更容易上手的第三方扩展包 —— go-i18n

在使用这个扩展包之前,先在项目根目录下运行如下命令下载相关的扩展包:

go get -u github.com/nicksnyder/go-i18n/v2/i18n
go get -u github.com/nicksnyder/go-i18n/v2/goi18n

下载完成后,我们可以运行 ls -l $GOPATH/bin | grep goi18n 确保 goi18n 命令已经在 $GOPATH 中了:

通过 go-i18n 自动生成翻译文件

接下来,我们来编写消息文本模板用于生成翻译文件。在这个项目中,只有一个消息提示文本,那就是访问的群组不存在时返回的 Cannot read thread,因此,我们在项目根目录下创建 messages.go,并基于 go-i18n 提供的类型编写消息模板如下:

package main

import "github.com/nicksnyder/go-i18n/v2/i18n"

var messages = []i18n.Message{
	i18n.Message{
		ID:          "threaf_not_fount",
		Description: "Thread not exits in db",
		Other:       "Cannot read thread",
	},
}

其中 ID 是消息文本的唯一标识,Other 则是对应的翻译字符串(默认是英文),然后基于 goi18n 命令自动生成翻译文件到 locales 目录(执行前先创建 locales 目录):

mkdir locales
goi18n extract -outdir=locales -format=json messages.go

这样,就会在 locales 目录下生成可以被 go-i18n 包识别并解析的 JSON 格式翻译文件 active.en.json

编写中文版本翻译文件

然后,要进行本地化编程,可以在同级目录下创建并编辑 active.zh.json 用于存放消息文本的中文翻译:

本地化配置初始化

回到在在线论坛项目,打开配置文件 config.json,新增本地化目录和语言配置:

{
  "App": {
    ...
    "Locale": "locales",
    "Language": "zh"
  },
  ...
}

然后在 config/config.go 中新增与之映射的结构体字段,以及对应的初始化设置:

package config

import (
    "encoding/json"
    "github.com/nicksnyder/go-i18n/v2/i18n"
    "golang.org/x/text/language"
    "log"
    "os"
    "sync"
)

type App struct {
    ...
    Locale       string
    Language     string
}

...

type Configuration struct {
    App App
    Db  Database
    LocaleBundle *i18n.Bundle
}

var config *Configuration
var once sync.Once

// 通过单例模式初始化全局配置
func LoadConfig() *Configuration {
    once.Do(func() {
        file, err := os.Open("config.json")
        if err != nil {
            log.Fatalln("Cannot open config file", err)
        }
        decoder := json.NewDecoder(file)
        config = &Configuration{}
        err = decoder.Decode(config)
        if err != nil {
            log.Fatalln("Cannot get configuration from file", err)
        }
        // 本地化初始设置
        bundle := i18n.NewBundle(language.English)
        bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
        bundle.MustLoadMessageFile(config.App.Locale + "/active.en.json")
        bundle.MustLoadMessageFile(config.App.Locale + "/active." + config.App.Language + ".json")
        config.LocaleBundle = bundle
    })
    return config
}

注意我们在 Configuration 结构体中新增了一个 *i18n.Bundle 类型的 LocaleBundle 字段,用于存放全局本地化 Bundle 实例,并且在 LoadConfig() 方法中以单例模式初始化该实例。

在处理器方法中返回本地化消息

接下来,我们打开 handlers/helper.go,在 init 方法中初始化 Localizer 以便被所有处理器方法使用:

package handlers

import (
    ...
    "github.com/nicksnyder/go-i18n/v2/i18n"
    . "github.com/xueyuanjun/chitchat/config"
)

var logger *log.Logger
var config *Configuration
var localizer *i18n.Localizer

func init()  {
    // 获取全局配置实例
    config = LoadConfig()
    // 获取本地化实例
    localizer = i18n.NewLocalizer(config.LocaleBundle, config.App.Language)
    ...
}

...

最后在 handlers/thread.go 和 handlers/post.go 中调用 errorMessage 辅助函数的地方调用 Localizer 提供的方法对消息文本进行翻译并返回给用户:

if err != nil {
    msg := localizer.MustLocalize(&i18n.LocalizeConfig{
        MessageID: "thread_not_found",
    })
    errorMessage(writer, request, msg)
} else {
    ...
}

测试消息本地化

重新启动应用,如果试图访问一个不存在的群组页面,就会返回如下中文提示信息:

说明我们的本地化翻译生效了,当然这里只是使用了 go-i18n 提供的最基本的功能,想要了解更多使用示例,可以参考官网文档。

视图本地化

所谓视图本地化指的是静态 HTML 视图模板的本地化,这里就不再适合使用消息文本翻译的方式实现了,最简单的方式就是为每个语言创建独立的视图模板进行本地化,然后在应用代码中通过读取全局配置、用户手动选择、客户端参数(比如 HTML 请求头中的 Accept-Language 字段)、或者域名信息来判断加载那种本地化视图模板,为了简化演示流程,这里我们使用全局配置的方式,也就是我们上面配置文件中设置的 Language 字段。

创建本地化视图模板

首先,我们在 views 目录下新增 en 和 zh 两个子目录,分别用于存放英文视图模板和中文视图模板,然后将原有视图文件移动到 en 目录下,并且在 zh 目录下创建每个视图模板的中文版本,以首页 index.html 为例,对应的中文版本如下:

其他中文视图模板也是类似,将其中的英文文本统一翻译成中文即可。

通过配置加载本地化视图

打开 handlers/helper.go,在 generateHTML 方法中通过读取全局配置加载对应的本地化视图模板:

func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {
    var files []string
    for _, file := range filenames {
        files = append(files, fmt.Sprintf("views/%s/%s.html", config.App.Language, file))
    }
    
    templates := template.Must(template.ParseFiles(files...))
    templates.ExecuteTemplate(writer, "layout", data)
}

注:同时移除 parseTemplateFiles 方法,并将调用该方法的地方调整为调用 generateHTML 以避免维护两个地方。

测试视图本地化

重启应用,访问首页,即可看到页面视图已经都是中文显示了:

日期时间本地化

看起来都已经 OK 了,不过还有个小问题,那就是日期时间显示还是英文风格的,对应的实现代码在 models/thread.go 中:

// format the CreateAt date to display nicely on the screen
func (thread *Thread) CreateAtDate() string {
	return thread.CreatedAt.Format("Jan 2, 2006 at 3:04pm")
}

我们当然可以直接修改这里来实现类似 2006-01-02 15:04:05 的日期时间格式(该时间节点是 Go 语言元年),不过,这里换一种复杂一点的实现,以便顺手介绍下如何在 Go 视图模板中通过管道模式调用自定义函数。

将自定义函数应用到视图模板

打开 handlers/helper.go,新增一个格式化日期时间的函数 formatDate,然后在 generateHTML 方法中将这个函数通过 template.FuncMap 组装后再通过 Funcs 方法应用到视图模板中,这样,就可以在所有视图模板中通过 fdate 别名来调用 formatDate 函数了:

// 生成 HTML 模板
func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {
    var files []string
    for _, file := range filenames {
        files = append(files, fmt.Sprintf("views/%s/%s.html", config.App.Language, file))
    }
    funcMap := template.FuncMap{"fdate": formatDate}
    t := template.New("layout").Funcs(funcMap)
    templates := template.Must(t.ParseFiles(files...))
    templates.ExecuteTemplate(writer, "layout", data)
}

...

// 日期格式化辅助函数
func formatDate(t time.Time) string {
    datetime := "2006-01-02 15:04:05"
    return t.Format(datetime)
}

用自定义函数格式化本地日期时间

然后我们在所有视图文件中将群组创建日期渲染调整为如下方式,即通过管道连接符的方式将 .CreatedAt 变量作为参数传入 fdate 并输出返回值:

{{ .CreatedAt | fdate }}

注意这里一定要使用 .CreatedAt,这个变量才是 time.Time 类型,而 .CreatedAtDate 是字符串类型。

再次重新启动应用,访问首页和群组详情页就可以看到格式化后的本地日期时间格式了:

(九):部署 Go Web 应用

简介

与 PHP 应用相比,部署 Go 应用相对简单,因为所有应用代码都被打包成一个二进制文件了(视图模板、静态资源和配置文件等非 Go 代码除外),并且不需要依赖其他库(PHP 需要安装各种扩展),不需要额外的运行时环境(比如 Java 需要再安装 JVM),也不需要部署额外的 HTTP 服务器(比如 PHP 还需要再启动 PHP-FPM 处理请求)。

对于在线论坛项目,包含了静态资源文件(CSS、JavaScript、图片),所以我们将在 Go Web 应用之前前置一个 Nginx 服务器处理静态资源请求,然后通过反向代理处理动态资源请求(指向 Go 处理器方法的请求),对于那些不包含静态资源和视图模板的纯 API 项目,通常只需要打包一份二进制文件部署到服务器即可,更加便捷。

注:其实 Go 应用部署的最佳实践是基于 Docker,后续我们在部署专题中再介绍如何基于 Docker 将应用快速部署到远程云服务器。

构建应用

首先,我们可以在本地项目根目录下通过如下命令将应用代码打包成二进制可执行文件:

GOOS=linux GOARCH=amd64 go build

注意这里指定了 GOOS 和 GOARCH 选项进行交叉编译,因为我们是在 Mac 系统(amd64)中打包,并且目标二进制文件需要在 Linux 服务器(linux)执行。该命令执行成功后会在当前目录下生成和项目名称相同的二进制文件:

然后我们可以将代码提交到 Github 或者其他代码仓库。

部署应用

部署代码

再登录服务器到部署目录下拉取代码:

git clone https://github.com/nonfu/chitchat

初次拉取使用 git clone,后续在 chitchat 目录下运行 git pull 即可。

然后我们进入 chitchat 目录,配置 config.json 进行服务端数据库配置(正式项目不要将 config.json 提交到代码仓库,以免安全风险和后续拉取代码覆盖),确保 logs 目录对 Web 用户具有写权限(比如配置权限为 777,或者所属用户与 Web 用户组一致)。

注:当然我们这里部署代码的方式比较原始,对于多人协作的大型项目,可以借助持续集成工具(比如 Jenkins)进行自动化部署,并且由于项目比较简单,就不再演示单元测试、CI/CD 等其他 DevOps 工具的使用了。

数据库初始化

在服务端 MySqL 数据库中创建 chitchat 数据库,并初始化对应数据表。

访问应用

完成以上工作后,我们就可以在 chitchat 项目目录下运行 chitchat 二进制文件启动应用了:

然后我们在本地 hosts 文件中自定义一个测试域名与服务器 IP 的映射:

your-server-ip-address chitchat.test

将上述 your-server-ip-address 替换成自己的远程服务器 IP 地址,然后我们就可以在浏览器中通过 http://chitchat.test:8080 访问应用了:

通过 Nginx 做反向代理

虽然上述方式可以正常运行,但是如果要高效处理静态资源文件并对其做缓存,可以借助 Nginx 作为反向代理服务器来完成,我们在 Nginx 虚拟主机配置目录 /etc/nginx/sites-available 中新增一个配置文件 chitchat.conf(以 Ubuntu 服务器为例):

server {
    listen      80; 
    server_name chitchat.test www.chitchat.test;
    
    # 静态资源交由 Nginx 管理,并缓存一天
    location /static {
        root        /var/www/chitchat/public;
        expires     1d;
        add_header  Cache-Control public;
        access_log  off;
        try_files $uri @goweb;
    }
    
    location / {
        try_files /_not_exists_ @goweb;
    }
    
    # 动态请求默认通过 Go Web 服务器处理
    location @goweb {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Scheme $scheme;
        proxy_redirect off;
        proxy_pass http://127.0.0.1:8080;
    }
    
    error_log /var/log/nginx/chitchat_error.log;
    access_log /var/log/nginx/chitchat_access.log;
}

然后再启用该配置文件:

ln -s /etc/nginx/sites-available/chitchat /etc/nginx/sites-enabled/chitchat

重启 Nginx 服务:

service nginx restart

与此同时,我们可以把 chitchat/config.json 中的 App 配置项启动 IP 地址改为 127.0.0.1

"App": {
    "Address": "127.0.0.1:8080",
    "Static": "public",
    "Log": "logs",
    "Locale": "locales",
    "Language": "zh"
  },

并再次重启这个 Go 应用,这样就只能通过 Nginx 访问应用,在浏览器中访问 http://chitchat.test

而当你试图再通过 http://chitchat.test:8080 访问应用,则会报错:

我们可以测试下注册登录功能以及创建新群组功能:

通过 Supervisor 维护应用守护进程

看起来一切都 OK 了,但是目前这种模式下,用户退出后 Go Web 应用进程会关闭,这显然是不行的,而且如果 Go Web 应用进程因为其他异常挂掉,也无法自动重启,每次需要我们登录到服务器进行启动操作,这很不方便,也影响在线应用的稳定性,为此,我们需要借助第三方进程监控工具帮我们实现 Go Web 应用进程以后台守护进程的方式运行。常见的进程监控工具有 Supervisor、Upstart、systemd 等,由于我的服务器之前部署过 Supervisor,所以我就借助它来管理 Go Web 应用进程。

首先创建对应的 Supervisor 配置文件 /etc/supervisor/conf.d/chitchat.conf,这里需要设置进程启动目录及命令、进程意外挂掉后是否自动重启、以及日志文件路径等:

[program:chitchat]
process_name=%(program_name)s
directory=/var/www/chitchat
command=/var/www/chitchat/chitchat
autostart=true
autorestart=true
user=root
redirect_stderr=true
stdout_logfile=/var/www/chitchat/logs/chitchat.log

注意:我们需要进入 chitchat 所在目录执行启动命令,否则会找不到配置文件和其他资源路径,所以需要配置 directory 选项。

然后关闭之前通过手动运行 chitchat 启动的 Go Web 服务器,再运行如下指令通过 Supervisor 启动并维护 Go Web 应用进程:

supervisorctl reread
supervisorctl update
supervisorctl start chitchat

你可以通过 ps -ef | grep chitchat 查看进程是否启动成功:

启动成功后,就可以在浏览器通过 http://chitchat.test 访问部署在远程服务器的在线论坛了:

并且无论是否退出远程服务器还是关闭 Go Web 应用进程,都不会影响在线论坛的访问,因为它是以守护进程的方式运行的,并且可以在关闭后自动重启。

(十):通过 Viper 读取配置文件并实现热加载

简介

之前我们在论坛项目中使用了单例模式全局加载配置文件,这样做有一个弊端,就是不支持热加载,每次修改配置文件,需要重启应用,不太灵活,所以这篇教程我们引入 Viper 重构配置读取逻辑,并支持配置文件的热加载(所谓热加载指的是配置文件修改后无需重启应用即可生效)。

Viper 是 Go 语言的完整配置解决方案,支持多个数据源和丰富的功能:

  • 支持设置默认配置值
  • 从 JSON、YAML、TOML、HCL 等格式配置文件读取配置值
  • 支持从 OS 中读取环境变量
  • 支持读取命令行参数
  • 支持从远程 KV 存储系统读取配置值,包括 Etcd、Consul 等
  • 可以监听配置值变化,支持热加载

配置好数据源,初始化并启动 Viper 后,就可以通过 viper.Get 获取任意数据源的配置值,非常方便,还可以调用 viper.Unmarshal 方法将配置值映射到指定结构体指针。

目前已经有很多知名 Go 项目在使用 Viper 作为配置解决方案,包括  Hugo、Docker Notary、Nanobox、EMC REX-Ray、BloomApi、doctl、Mercure 等。

话不多说,下面我们在论坛项目中引入 Viper 来加载配置文件。

使用 Viper 加载配置

开始之前,先安装 Viper 扩展包:

go get github.com/spf13/viper

然后,我们在 config 目录下创建 viper.go 来定义基于 Viper 的配置初始化逻辑:

package config

import (
	"encoding/json"
	"fmt"

	"github.com/nicksnyder/go-i18n/v2/i18n"
	"github.com/spf13/viper"
	"golang.org/x/text/language"
)

var ViperConfig Configuration

func init() {
	runtimeViper := viper.New()
	runtimeViper.AddConfigPath(".")
	runtimeViper.SetConfigName("config")
	runtimeViper.SetConfigType("json")
	err := runtimeViper.ReadInConfig()
	if err != nil {
		panic(fmt.Errof("Fatal error config file: %s \n", err))
	}
	runtimeViper.Unmarshal(&ViperConfig)

	// 本地化初始设置
	bundle := i18n.NewBundle(language.English)
	bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
	bundle.MustLoadMessageFile(config.App.Locale + "/active.en.json")
	bundle.MustLoadMessageFile(config.App.Locale + "/active." + config.App.Language + ".json")
	config.LocaleBundle = bundle
}

这里,我们基于项目根目录下的 config.json 作为配置文件,在 init 方法中对应的配置文件设置代码是如下这三行:

runtimeViper.AddConfigPath(".")
runtimeViper.SetConfigName("config")
runtimeViper.SetConfigType("json")

. 表示项目根目录,config 表示配置文件名(不含格式后缀),json 表示配置文件格式,然后通过 runtimeViper.ReadInConfig() 从配置文件读取所有配置,再通过 runtimeViper.Unmarshal(&ViperConfig) 将其映射到之前定义的位于 config.go 中的 Configuration 结构体变量 ViperConfig。接下来,就可以通过 ViperConfig 变量访问系统配置了,不过,由于本地化 LocaleBundle 属性需要额外初始化,所以我们参照单例模式中的实现对其进行初始化。ViperConfig 变量对外可见,所以只要引入 config 包的地方,都可以直接访问 ViperConfig 属性来读取配置值(init 方法会自动调用并且全局执行一次,所以无需考虑性能问题)。

重构配置读取代码

接下来,我们可以重构之前业务代码中的配置读取代码,首先是 main.go

// 通过指定端口启动 Web 服务器
func startWebServer(port string) {
	// 在入口位置初始化全局配置
	config := LoadConfig()
	// 使用的路由器正是上述 router.go 中 NewRouter 方法返回的 mux.Router 指针类型实例
	r := NewRouter() // 通过 router.go 中定义的路由器来分发请求

	// 处理静态资源文件
	// http.FileServer 用于初始化文件服务器和目录为当前目录下的 public 目录。
	// assets := http.FileServer(http.Dir("public"))
	assets := http.FileServer(http.Dir(ViperConfig.App.Static))
	// 指定静态资源路由及处理逻辑
	// 将 /static/ 前缀的 URL 请求去除 static 前缀
	// 然后在文件服务器查找指定文件路径是否存在(public 目录下的相对地址)
	r.PathPrefix("/static/").Handler(http.StriPrefix("/static/", assets))

	http.Handler("/", r) // 应用路由器到 HTTP 服务器
    
	// log.Println("Starting HTTP service at " + port)
	log.Println("Starting HTTP service at " + ViperConfig.App.Address)
	// 指定 HTTP 服务器监听 8080 端口
	// err := http.ListenAndServe(":" + port, nil) // 启动协程监听请求
    err := http.ListenAndServe(ViperConfig.App.Address, nil)

	if err := nil {
		// log.Println("An error occured startung HTTP listener at port " + port)
		log.Println("An error occured startung HTTP listener at port " + ViperConfig.App.Address)
		log.Println("Error: " + err.error())
	}
}

然后是 handlers/helper.go

func init()  {
    // 获取本地化实例
    localizer = i18n.NewLocalizer(ViperConfig.LocaleBundle, ViperConfig.App.Language)
    file, err := os.OpenFile(ViperConfig.App.Log + "/chitchat.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
    ...
}

...

// 生成 HTML 模板
func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {
    var files []string
    for _, file := range filenames {
        files = append(files, fmt.Sprintf("views/%s/%s.html", ViperConfig.App.Language, file))
    }
    ...
}

最后是 models/db.go

func init() {
    var err error
    driver := ViperConfig.Db.Driver
    source := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8&parseTime=true", ViperConfig.Db.User, ViperConfig.Db.Password,
        ViperConfig.Db.Address, ViperConfig.Db.Database)
    ...
}

业务逻辑非常简单:取消了之前通过 LoadConfig 方法以单例模式初始化全局配置,改为直接调用 ViperConfig 对象属性获取配置值。

通过 Viper 实现热加载

但是现在配置文件依然不支持热加载,不过 Viper 提供了对应的 API 方法实现该功能,我们打开 config/viper.go,在 init 方法最后加上如下这段代码:

func init()  {
    ...

    // 监听配置文件变更
    runtimeViper.WatchConfig()
    runtimeViper.OnConfigChange(func(e fsnotify.Event) {
        runtimeViper.Unmarshal(&ViperConfig)
        ViperConfig.LocaleBundle.MustLoadMessageFile(ViperConfig.App.Locale + "/active." + ViperConfig.App.Language + ".json")
    })
}

我们通过 runtimeViper.WatchConfig() 方法监听配置文件变更(该监听会开启新的协程执行,不影响和阻塞当前协程),一旦配置文件有变更,即可通过定义在 runtimeViper.OnConfigChange 中的匿名回调函数重新加载配置文件并将配置值映射到 ViperConfig 指针,同时再次加载新的语言文件。

这样,我们就实现了在 Go 项目中通过 Viper 实现配置文件的热加载,当然,这里只是一个简单的示例,Viper 还支持更丰富的数据源和配置操作,如果你想要了解更多可以参考Viper官方文档。

测试配置文件读取和热加载

接下来,我们启动应用:

启动成功,则表示通过 Viper 可以正确读取 App 配置,然后访问应用首页:

一切都正常,表示数据库配置读取也是 OK 的,本地化设置也正常,为了测试配置文件的热加载,我们将 App.Language 配置值设置为 en

{
  "App": {
    "Address": "0.0.0.0:8080",
    "Static": "public",
    "Log": "logs",
    "Locale": "locales",
    "Language": "en"
  },
  ...
}

在不重启应用的情况下,刷新论坛首页:

页面变成通过英文模板渲染的了,表明配置文件热加载生效。

【资源说明】 毕业设计基于Golang开发的BBS论坛系统源码+项目使用说明.zip 特性 注册/登陆模块(用户名或邮箱登陆) 设置昵称、邮箱、用户名 发表动态、文章 评论系统 动态/文章的点赞 支持浏览器 token 记住登录 支持文章或评论流式获取 支持 markdown 语法发表文章或评论 用户资料编辑 - [ ] 站内信 - [ ] 文章标签管理 技术选型 - 后端:整体使用 golang 编写,用 Gin 框架搭建 API 部分 - 包管理:go-mod - 配置文件:使用 viper 实现的 yaml 格式的配置文件 - 日志:基于 zap 实现的日志系统 - 数据库:使用 mysql-5.7,采用 gorm 库来操作数据库 - 前端:基于 Vue.js 编写,使用 Nuxt.js 快速构建和渲染前端 目录结构 ``` . ├── LICENSE ├── api (API文件夹) ├── bbs.yaml (配置文件) ├── build.sh (构建脚本,构建可在linux上运行的二进制文件) ├── config (配置包) ├── logs (日志包) ├── main.go (main函数) ├── middleware (中间件) ├── model (结构体) ├── nbbs.service (linux服务配置文件) ├── repository (数据库层) ├── service (服务层) ├── util (通用工具) ├── site (前端) │ ├── Dockerfile (docker文件) │ ├── app.html (app) │ ├── assets (静态文件) │ ├── common (通用工具) │ ├── components (通用组件) │ ├── jsconfig.json (配置) │ ├── layouts (布局) │ ├── middleware (中间件) │ ├── nuxt.config.js (nuxt配置) │ ├── pages (页面组件) │ ├── plugins (插件) │ ├── start.sh (运行脚本) │ ├── static (静态文件) │ ├── store (vuex状态管理仓) │ └── utils (通用工具) ``` 安装说明 # 1.获取源码 csdn下载项目源码,并解压 # 2.创建 mysql 中的数据库 在 mysql 中创建好 database,在步骤 3 中填入 database 的信息,无需创建数据表 示例: ```shell ceate database neighborbbs; ``` # 3.修改配置 修改 bbs.yaml 文件,配置 mysql、服务端口、日志等信息 示例: ```yaml mysql: host: 127.0.0.1 port: 3306 username: root password: 123456 dbname: neighborbbs ``` # 4.启动后端 > 如果没有 go 环境,请先安装和配置 go 环境 ## 安装依赖 ```shell go mod download ``` ## 启动服务 **方式一** ```shell go run main.go ``` **方式二** ```shell go build #编译项目 ./NeighborBBS #执行二进制 ``` **方式三** ```shell ./build 【备注】 1、该资源内项目代码都经过测试运行成功,功能ok的情况下才上传的,请放心下载使用! 2、本项目适合计算机相关专业(如计科、人工智能、通信工程、自动化、电子信息等)的在校学生、老师或者企业员工下载使用,也适合小白学习进阶,当然也可作为毕设项目、课程设计、作业、项目初期立项演示等。 3、如果基础还行,也可在此代码基础上进行修改,以实现其他功能,也可直接用于毕设、课设、作业等。 欢迎下载,沟通交流,互相学习,共同进步!
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值