0%

go-web-2

摘要

此文会一步步去构建一个简单的网上论坛,以此来在实践中展示如何使用Go构建一个典型的Web应用。它允许用户登录到论坛里面,然后在论坛上上发布新帖子,又或者回复其他用户的帖子。

2.1 ChitChat 功能介绍

为了让这个例子保持简单,我们只实现网上论坛的关键特性:在这个论坛里,用户可以注册账号,并在登录之后发表新帖子又或者回复已有帖子;为注册用户可以查看帖子,但无法发表帖子或是回复帖子。

2.2 应用设计

ChitChat的应用逻辑会被编码到服务器里,服务器会向客户端提供HTML页面。

请求(客户端->服务器):http://<服务器名><处理器名>?<参数>

参数:会以URL查询的形式传递给处理器,而处理器则会根据这些参数对请求处理。、

栗子:

1
2
http://chitchat/thread/read?id=123
//123为这个帖子的唯一id

工作过程

01

多路复用器(multiplexer):会对请求进行检查,并将请求重定向至正确的处理器进行处理。

2.3 数据模型

将ChitChat的数据储存到关系型数据库PostgreSQL里,并通过SQL与之交互。

数据模型

  • User:表示论坛的用户信息
  • Session:表示论坛用户当前的登录会话
  • Thread:表示论坛里面的帖子,每个帖子都记录了多个论坛用户之间的对话
  • Post:表示用户在帖子里面添加的回复

以上4种数据结构都会被映射到关系型数据库中。

交互过程

02

2.4 请求的接受与处理

我们以main.go开始:

多路复用器

1
2
3
4
5
6
7
8
9
10
11
package main

import "net/http"

func main(){
mux := http.NewServeMux()//创建一个多路复用器mux
files := http.FileServer(http.Dir("/public"))//创建能为指定目录中的静态文件服务的处理器files
mux.Handle("/static/", http.StripPrefix(("/static"),files))
//将files处理器传递给多路复用器的Handle函数,使用StripPrefix函数去移除请求URL中的指定前缀
mux.HandleFunc("/",index)//(URL,处理器名称)
}

创建:NewServeMux(由net/http标准库提供)

实现功能:

  • 负责将请求重定向到处理器:

    函数HandleFunc 将发送至URL的请求重定向到处理器(URL,处理器名称)。

    mux.HandleFunc("/",index) :当对针对根URL的请求到达时,该请求就会被重定向到名为index的处理器函数

  • 还需要为静态文件提供服务:

    函数FileServer创建一个能为指定目录中的静态文件服务的处理器。

    函数StripPrefix可以移除请求URL中的指定前缀。

    files := http.FileServer(http.Dir("/public"))

    mux.Handle("/static/", http.StripPrefix(("/static"),files))

    当服务器接收到一个以/static/开头的URL请求时,以上两行代码会移除URL中的/static/字符串,然后在public目录中查找被请求的文件。

    栗子

    接收到对文件http://localhost/static/css/bootstrap.min.css的请求时,它将在public目录中查找文件:<application root>/css/bootstrap.min.css,客户找到这个文件后,会把它返回给客户端。

创建处理器函数

处理器函数实际上就是一个可以接受ResponseWriter和Request指针作为参数的Go函数。

1
2
3
4
5
6
7
8
func index(w http.ResponseWriter, r *http.Request){
threads, err := data.Threads()
files := []string{"template/layout.html", "template/navbar.html", "template/index.html"}
templates := template.Must(template.ParseFiles(files...))
if err == nil {
templates.ExecuteTemplate(w, "layout", threads)
}
}

这涉及到解析和处理模板渲染的操作,需要用到html/template标准库中的Template结构。

使用cookie进行访问控制

ChitChat既拥有任何人都可以访问的公开页面,也拥有用户在登录账号后才能看到的私人页面。

当用户成功登入后,服务器必须在后续的请求中标示出这是又一个已经登录的用户。为做到这一点,服务器会在响应的首部中写入一个cookie,而客户端在接受这个cookie之后则会将它存储到浏览器里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func authenticatefunc authenticate(w http.ResponseWriter, r *http.Request){
r.ParseForm()
user, _ := data.UserByEmail(r.PostFormValue("email"))
if user.Password == data.Encrypt(r.PostFormValue("password")){//验证密码是否一致
session := user.CreateSession()
cookie := http.Cookie{
Name: "_cookie",
Value: session.Uuid,
HttpOnly: true,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/",302)
}else{
http.Redirect(w, r, "/login",302)
}
}

函数data.UserbyEmail通过给定的电子邮件地址获取与之对应的User结构。

函数data.Encrypt用于加密给定的字符串。

在验证密码核实了身份后,程序会使用User结构的CreateSession方法创建一个Session结构

1
2
3
4
5
6
7
8
type Session struct{
Id int //存储一个随机生成的唯一ID(实现对话机制的核心),服务器会通过cookie把这个ID存储到浏览器里
Uuid string
//并把Session结构中记录的各项信息存储到数据库里
Email string //存储用户电子邮件地址
UserId int //记录用户表中存储用户信息的行的ID
CreatedAt time.Time
}

cookie := http.Cookie{...}:创建cookie结构。

cookie的Name是随意的,而Value是将要存储到浏览器里面的唯一ID

程序没有给cookie设置过期时间,所以这个cookie就成了一个对话cookie,它将在浏览器关闭时自动被移除。

HttpOnly: true:这个cookie只能通过HTTP或HTTPS访问,但无法通过JavaScript等非HTTP API进行访问。

http.SetCookie(w, &cookie)将cookie添加到相应的首部去


在cookie存储到浏览器之后,接下来需要在处理器函数里面检查当前的用户是否已经登录

我们需要创建一个名为session的工具函数,并在处理器函数(index)里面复用它。

注:我们将所有的工具(utility)函数都放在util.go文件中。

1
2
3
4
5
6
7
8
9
10
func session(w http.ResponseWriter, r *http.Request)(sess data.Session, err error){
cookie, err := r.Cookie("_cookie")
if err == nil{
sess = data.Session{Uuid: cookie.Value}
if ok,_ := sess.Check(); !ok {
err = errors.New("Invalid session")
}
}
return
}

两项检查

  • cookie, err := r.Cookie("_cookie"):从请求中取出cookie,如果cookie不存在,则未登录;

  • 访问数据库并核实对话的唯一ID是否存在。

从cookie中取出对话赋给sess,调用sess的Check方法。

处理器函数使用session函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func index(w http.ResponseWriter, r *http.Request){
threads, err := data.Threads()
if err ==nil{
_, err :=session(w, r)
public_tmpl_files := []string{"template/layout.html", "template/public.navbar.html", "template/index.html"}
private_tmpl_files := []string{"template/layout.html", "template/private.navbar.html", "template/index.html"}
var templates *template.Template
if err != nil{
templates = template.Must(template.ParseFiles(public_tmpl_files...))
}else{
templates = template.Must(template.ParseFiles(private_tmpl_files...))
}
templates.ExecuteTemplate(w, "layout", threads)
}
}

通过调用session可以取得一个存储了用户信息的Session结构,不过因为index函数目前不需要这些信息,使用空白表示符(_)忽略这一结构。

其真正感兴趣的是err变量。通过err变量可判断用户是否登录,然后选择使用public导航条还是使用private导航条。

2.5 使用模板生成HTML响应

index处理器函数中大部分代码都是用来为客户端生成HTML的。

函数把每个需要的模板文件都放到了Go切片里面:

1
2
3
public_tmpl_files := []string{"template/layout.html", 
"template/public.navbar.html",
"template/index.html"}

templates = template.Must(template.ParseFiles(public_tmpl_files...))

程序会调用ParseFiles函数对这些模板文件进行语法分析,并创建出相应的模板。

为了捕捉语法分析过程中可能会产生的错误,程序使用了Must函数去包围ParseFiles函数的执行结果,这样当ParseFiles返回错误的时候,Must函数就会向用户返回响应的错误报告。

现在来看模板文件:

Chitchat项目中每个项目文件都定义了一个模板(当然也可以在一个模板文件中定义多个模板)

切片指定的这三个HTML文件都包含了特定的嵌入命令,这些命令被称为动作(action),动作在HTML文件中会被{{ }}包围起来。

layout.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{{ define "layout"}}

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chitchat</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/font-asesome.min.css">
</head>
<body>
{{ template "navbar" . }}
<div class="container">
{{ template "content" . }}
</div>

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

{{end}}

动作包括:

  • define:这个动作通过文件开头的{{ define "layout"}}和文件末尾的{{ end }}将报文的文本定义成了layout模板的一部分
  • navbar:引用navbar.html模板。跟在被引用模板名字之后的点(.)代表了传递给被引用模板的数据(当然,这种传递是相互传递的过程,在navbar.html中如有需要不必再引用layout)

  • content:引用content 模板,而content模板在index.html的位置,可见,实际上模板和模板文件分别拥有不同的名字也是可行的。

public.navbar.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{{ 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="/login">Login</a></li>
</ul>
</div>
</div>
</div>
{{ end }}

除了定义模板自身的define动作之外,这个模板没有包含其他动作。

index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{{ define "content" }}
<p class="lead">
<a href="/thread/new">Start a thread</a> or join one below!
</p>

{{ range . }}
<div 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>
</div>
{{ end }}

{{ end }}

再来看index中这段代码:

templates.ExecuteTemplate(w, "layout", threads)

程序通过调用函数ExecuteTemplate,执行已经语法分析过的layout模板。执行模板意味着把模板文件中的内容和来自其他渠道的数据进行合并,然后生成最终的HTML内容。

而模板文件对数据的引用正是通过{{.}}来实现。

程序之所以对layout模板而不是navbar模板或者content模板进行处理,是因为layout模板已经引用了其他两个模板,所以执行layout模板就会导致其他两个模板也会被执行,由此产生预期的HTML。

栗子:

1
{{ .User.Name }} - {{ .CreatedAtDate }} - {{ .NumReplies }}

03

代码整理

因为生成HTML的代码会被重复执行很多次,我们这里将其封装为一个函数generateHTML:

1
2
3
4
5
6
7
8
func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string){
var files []string
for _, file := range filenames{
files = append(files, fmt.Sprintf("templates/%s.html",file))
}
templates := template.Must(template.ParseFiles(files...))
templates.ExecuteTemplate(writer,"layout",data)
}

这里data是一个空接口,可以传递任何类型的值。

filenames ...string:最后一个参数以3个点开头(…)开头,表示generateHTML函数是一个可变参数函数,这意味着这个函数可以在最后的可变参数中接受零个或任意多个值作为参数。其对可变参数的支持使我们可以同时将任意多个模板文件传递给该函数。

注意:在Go中,可变参数必须是可变参数函数的最后一个参数。

index处理器整理后的最终版:

1
2
3
4
5
6
7
8
9
10
11
func index(writer http.ResponseWriter, request *http.Request){
threads, err :=data.Threads();
if err ==nil {
_, err := session(writer, request)
if err != nil {
generateHTML(writer, threads, "layout","public.navbar","index")
} else {
generateHTML(writer, threads, "layout","private.navbar","index"))
}
}
}

2.6 使用数据库(PostgreSQL)

在Windows系统上安装:

一个比较流行的安装程序是由Enterprise DB提供的:www.enterprisedb.com/products-services-training/pgdownload.

这里我们创建一个名为data的包,包含与数据库交互的结构代码,还包含了一些与数据处理密切相关的函数

我们在data目录中穿件一个名为thread.go的文件,用于保存的帖子相关代码。(当然还有保存所有用户先关代码的user.go)

thread.go中定义的Thread结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
package data

import (
"time"
)

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

之后引用Thread结构就需要:data.Thread

Thread结构应该与创建关系数据库表threads时使用的数据定义语言(Data Define Language,DDL)保持一致。

我们现在需要创建一个容纳该表threads的数据库。

创建数据库:createdb chitchat

创建完数据库后,就可以通过setup.sql文件来创建数据库表了:

setup.sql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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
);

运行这个脚本需要用到psql(SQL shell)

在终端执行:psql -f setup.sql -d chitchat 将在chitchat数据库创建出相应的表

下面,考虑如何与数据库进行连接以及如何对表进行操作

data.go中的Db全局变量和init函数:

1
2
3
4
5
6
7
8
9
10
var Db *sql.DB

func init() {
var err error
Db, err = sql.Open("postgres", "dbname=chitchat sslmode=disable")
if err != nil {
log.Fatal(err)
}
return
}
  • 创建了一个Db全局变量指针,它指向代表数据库连接池的sql.DB,后续会用这个Db执行数据库查询操作。

现在,我们拥有了Thread结构、threads数据库表和一个指向数据库连接池的指针。

下面实现Thread结构与threads数据库表的连接(connect)

我们创建一个能在结构和数据库之间互动的函数就OK。

从数据库里面取出所有帖子并将其返回给index

threads.go中的Threads函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Threads Get all threads in the database and returns it
func Threads() (threads []Thread, err error) {
rows, err := Db.Query("SELECT id, uuid, topic, user_id, created_at FROM threads ORDER BY created_at DESC")//通过数据库连接池与数据库连接+SQL语句+使用这个结构去存储行中记录的子数据
if err != nil {
return
}
for rows.Next() {//循环:遍历行row,为每个行分别创建一个Thread结构切片conv,
conv := Thread{}
if err = rows.Scan(&conv.Id, &conv.Uuid, &conv.Topic, &conv.UserId, &conv.CreatedAt); err != nil {
return//使用Scan函数将每行中记录的子数据存储到conv中
}
threads = append(threads, conv)//将conv内容追加到threads切片里面
}
rows.Close()
return
}

threads函数返回给index处理器之后,我们再来回顾index.html模板文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{{ define "content" }}
<p class="lead">
<a href="/thread/new">Start a thread</a> or join one below!
</p>

{{ range . }}
<div 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>
</div>
{{ end }}

{{ end }}

我们知道模板动作中的.代表传入模板的数据。

{{range .}}.表示程序在稍早之前通过Threads函数取得的Threads变量(切片)。

range动作:假设传入的数据要么是一个由结构组成的切片,要么是一个由结构组成的数组,这个动作会遍历传入的每个结构。

  • 访问字段:用户可以通过字段名访问结构里的字段,如{{.Topic}}访问的是Thread结构的Topic字段。

注意:访问字段必须在字段名前加.并字段名首字母大写。

  • 调用方法:例如{{.User.Name}}{{.CreatedAtDate}}{{.NumReplies}}等动作都是调用了结构中的同名方法。

thread.go中的NumReplies方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// NumReplies get the number of posts in a thread
func (thread *Thread) NumReplies() (count int) {
rows, err := Db.Query("SELECT count(*) FROM posts where thread_id = $1", thread.Id)
if err != nil {
return
}
for rows.Next() {
if err = rows.Scan(&count); err != nil {
return
}
}
rows.Close()
return
}

这是一个统计帖子数量的函数。

思想与Threads函数类似,最终返回帖子数量count的值,而模板引擎则使用这个值去带图模板文件中出现的{{ . NumReplies}}动作。

2.7 启动服务器

1
2
3
4
5
server := &http.Server{
Addr: "0.0.0.0:8080"
Handler: mux,
}
server.ListenAndServe()

启动服务器并将多路复用器与服务器绑定。

之后启动监听,服务器就可以启动了。

运行程序,服务器地址:http://localhost:8080/s