Published By getbook

Go-Mega Tutorial

BONFY

2018-11-06

Preface

Go-Mega Tutorial is generated by getbook.

This book contains 18 chapters. Take some time, enjoy the book.

getbook is a tool to generate ebooks from a collection of web links. You can find it at https://github.com/lepture/getbook.

Go-Mega

cover

Go-Mega

​​​​

大家好，我是 BONFY ，拥有多年开发Web的经验，不过想起我当时入门 Python Web 的时候也是看了当时 Miguel Grinberg 的博客，按照他的教程一步一步搭建最简单的博客，再到自己去研究 Flask 源码以及其他的 Web 框架。

目前由于工作需要，我经常要用 Go 开发Web应用，也积攒了一些经验和方法，所以就想模仿 The Flask Mega-Tutorial教程 来编写一套 Go 的 Web 教程。

本书的目录我尽量和 The Flask Mega 保持一致，不过对于有些章节也会酌情删减。

章节总览

Title

Source Code

Zip

​00-Prepare​

​

​

​01-Hello-World​

​View​

​Download​

​02-Template-Basic​

​View​

​Download​

​03-Template-Advance​

​View​

​Download​

​04-Web-Form​

​View​

​Download​

​05-Database​

​View​

​Download​

​06-User-Login​

​View​

​Download​

​07-Profile-Page-And-Avatar​

​View​

​Download​

​08-Follower​

​View​

​Download​

​09-Pagination​

​View​

​Download​

​10-Email-Support​

​View​

​Download​

​11-Facelift​

​View​

​Download​

​12-Dates-And-Times​

​View​

​Download​

​13-Javascript-Magic​

​View​

​Download​

​14-Deployment-On-Heroku​

​View​

​Download​

​15-Deployment-On-Linux​

​View​

​Download​

​16-Summary​

​

​

声明

这意味着你可以在非商业性使用的前提下自由转载，但必须：

保持署名

不对本书进行修改

联系我

如果你对于本书有什么建议或意见，欢迎批评指正，并联系我。

相关资源

支持我

如果您感觉有收获，欢迎给我打赏，以激励我输出更多优质内容，感谢支持！

感谢支持

00-Prepare

语言

安装Go,详情见参照Go的官方网站的安装文档

这里介绍下Mac下的安装，适用homebrew：

$ brew install go

# 安装完成后 查看版本
$ go version
go version go1.11 darwin/amd64

# 查看 go env
$ go env

编辑器

Goland (IDE）

Visual Studio Code （本人推荐，易用，插件易安装）

VI / Emacs (都是神级编辑器，不过对使用者的要求比较高)

这里推荐使用 Visual Studio Code

$ brew cask install visual-studio-code

当编写Go代码时，即遇到 .go结尾的文件时，它会自动提示你安装插件，十分简便。

体会

Go 是一门新生的语言，而且有个好的公司推广（google），而且这几年的势头也是很猛，是一门改良型的C语言

就 Web 编程而言，相对于Python

原生 net/http 支持，可以不需要依赖于第三方包

底层 goroutine，高并发

静态语言编译，高效

特殊的 error 处理机制，基本上一次编译成功，后面很少出错

编译之后二进制，易于部署

Links

目录

下一节: 01-Hello-World

01-Hello World

一般计算机书的开头都是 Hello World

我们亦不能免俗，所以本章我们的任务就是完成最简单的 Hello World

本章的GitHub链接为： Source, Zip

建立目录结构

与 Python 相比，Go 对代码存放的位置还是有讲究的，毕竟这是由 Go 特殊的 package引用机制 决定的，首先建立自己存放这次代码的文件夹

$ cd $GOPATH/src
$ mkdir -p github.com/bonfy/go-mega-code
$ cd github.com/bonfy/go-mega-code

这里如果大家有Github账号，而且想上传到自己的repo的话，建议 github.com/your_user_name/repo_name 的文件夹

Hello World 应用

在 github.com/bonfy/go-mega-code 文件夹下 建立 main.go，这是我们程序的主入口

main.go

package main

import "net/http"

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello World"))
    })
    http.ListenAndServe(":8888", nil)
}

短短不到10行代码，我们的 Hello World 应用就已经完成了，而且不需要任何的其他第三方Package，只需要引入官方的 net/http 就行了，就是这么easy

让我们来运行下面的命令，看下效果

$ go run main.go

现在打开您的网络浏览器并在地址栏中输入以下URL：

http://localhost:8888

或者

http://127.0.0.1:8888

说明

这里对上面的代码进行简单的说明

这里的 func main() 是主程序入口，主要用到了 net/http 的两个函数

func HandleFunc(pattern string, handler func(ResponseWriter, *Request))

func ListenAndServe(addr string, handler Handler) error

HandleFunc 类似于 flask的 app.route, pattern 提供了路由路径，handler是一个函数参数，这里我们的程序中传入的是一个匿名函数， 减少了代码

ListenAndServe 第一个参数为 addr，如果不提供ip，这里只传入端口，相当于 0.0.0.0:8888 ，第二个参数 Handler 传入 nil，则表示使用 Default 的 Server

另外 输出　Hello World 的办法，大致有三个，如下:

// Case 1: w.Write byte
w.Write([]byte("Hello World"))

// Case 2: fmt.Fprintf
fmt.Fprintf(w, "Hello World")

// Case 3: io.Write
io.WriteString(w, "Hello World")

其中第一种用的是 ResponseWriter 的 Write([]byte) (int, error) 方法， 而 后面两种是稍微用到了 Go 里面interface 的特性， ResponseWriter interface 要实现 Write([]byte) (int, error) 的方法，所以也就实现了 io.Writer 方法，所以可以作为 io.Writer 的类型作为 后面两个函数的参数。

这里如果想更深入的了解 net/http 处理请求的话，可以看下Go源码中的 net/http/server.go

或者看下 Go的http包详解

Links

目录

上一节: 00-Prepare

下一节: 02-Template-Basic

02-Template Basic

学习完 第一章 之后，你已经拥有了一个简单，不过可以成功运行Web应用

本章将沿用这个应用，在此之上，加入模版渲染，使得页面更丰富

本章的GitHub链接为： Source, Diff, Zip

什么是模板

微博应用程序的主页会有一个欢迎用户的标题。虽然目前的应用程序还没有实现用户概念，但这不妨碍我使用一个 Go Stuct 来模拟一个用户，由于Go是静态语言，先定义 User 的结构，然后再初始化 user ，如下所示：

type User struct {
    Username string
}

user := User{Username: "bonfy"}

创建模拟对象是一项实用的技术，它可以让你专注于应用程序的一部分，而无需为系统中尚不存在的其他部分分心。 在设计应用程序主页的时候，我可不希望因为没有一个用户系统来分散我的注意力，因此我使用了模拟用户对象，来继续接下来的工作。

原先的视图函数返回简单的字符串，我现在要将其扩展为包含完整HTML页面元素的字符串，如下所示：

package main

import (
    "html/template"
    "net/http"
)

// User struct
type User struct {
    Username string
}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        user := User{Username: "bonfy"}
        tpl, _ := template.New("").Parse(`<html>
            <head>
                <title>Home Page - Bonfy</title>
            </head>
            <body>
                <h1>Hello, {{.Username}}!</h1>
            </body>
        </html>`)
        tpl.Execute(w, &user)
    })
    http.ListenAndServe(":8888", nil)
}

对HTML标记语言不熟悉的话，建议阅读一下Wikipedia上的简介HTML Markup

利用上述的代码更新这个视图函数，然后再次运行 (这里并没有flask那样子的debug mode，每次更新都必须重新执行)

$ go run main.go

在浏览器打开它的URL看看结果

本小节 Diff

我们暂时将 template 以文本方式耦合在代码中，随着我们项目的扩大，这个不利于管理，而且如果公司有前端支持的话，模板的工作很大可能会有前端设计，所以我们有必要对其进行解偶。我们打算将模板文件放入 templates 的文件夹

建立 templates 文件夹

$ mkdir templates

将模板的内容移到 templates文件夹下

templates/index.html

<html>
    <head>
        <title>Home Page - Bonfy</title>
    </head>
    <body>
        <h1>Hello, {{.Username}}!</h1>
    </body>
</html>

然后将 template 文本从代码中移除

main.go

package main

import (
    "html/template"
    "net/http"
)

// User struct
type User struct {
    Username string
}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        user := User{Username: "bonfy"}
        tpl, _ := template.ParseFiles("templates/index.html")
        tpl.Execute(w, &user)
    })
    http.ListenAndServe(":8888", nil)
}

然后运行，在浏览器中打开，结果是与刚才一样的，不过代码的组织结构却更清晰了。

本小节 Diff

模板的常用操作

条件语句

在 index.html 中加入条件语句

templates/index.html

<html>
    <head>
        {{if .Title}}
            <title>{{.Title}} - blog</title>
        {{else}}
            <title>Welcome to blog!</title>
        {{end}}
    </head>
    <body>
        <h1>Hello, {{.User.Username}}!</h1>
    </body>
</html>

由于 User 没有 Title字段，所以我们引入新的 IndexViewModel 来具体处理所有与View对应的Model

main.go

package main

import (
    "html/template"
    "net/http"
)

// User struct
type User struct {
    Username string
}

// IndexViewModel struct
type IndexViewModel struct {
    Title string
    User  User
}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        user := User{Username: "bonfy"}
        v := IndexViewModel{Title: "Homepage", User: user}
        tpl, _ := template.ParseFiles("templates/index.html")
        tpl.Execute(w, &v)
    })
    http.ListenAndServe(":8888", nil)
}

本小节 Diff

循环

在 index.html 中加入循环

<html>
    <head>
        {{if .Title}}
            <title>{{.Title}} - blog</title>
        {{else}}
            <title>Welcome to blog!</title>
        {{end}}
    </head>
    <body>
        <h1>Hello, {{.User.Username}}!</h1>
        {{range .Posts}}
            <div><p>{{ .User.Username }} says: <b>{{ .Body }}</b></p></div>
        {{end}}
    </body>
</html>

在 main.go 的 IndexViewModel 中加入 Post

package main

import (
    "html/template"
    "net/http"
)

// User struct
type User struct {
    Username string
}

// Post struct
type Post struct {
    User User
    Body string
}

// IndexViewModel struct
type IndexViewModel struct {
    Title string
    User  User
    Posts []Post
}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        u1 := User{Username: "bonfy"}
        u2 := User{Username: "rene"}

        posts := []Post{
            Post{User: u1, Body: "Beautiful day in Portland!"},
            Post{User: u2, Body: "The Avengers movie was so cool!"},
        }

        v := IndexViewModel{Title: "Homepage", User: u1, Posts: posts}
        tpl, _ := template.ParseFiles("templates/index.html")
        tpl.Execute(w, &v)
    })
    http.ListenAndServe(":8888", nil)
}

本小节 Diff

运行效果图

Links

目录

上一节: 01-Hello-World

下一节: 03-Template-Advance

03-Template Advance

学习完 第二章 之后，你对模板已经有了基本的认识

本章将讨论 Go 的组合特性，以及 建立一个通用的调用 模板的方法

本章的GitHub链接为： Source, Diff, Zip

匿名组合

匿名组合 其实是Go里的一个非常重要的特性，在 Go 的世界里没有继承，只有组合（当然还有接口）。组合其实可以实现部分的继承。

main.go

type Post struct {
    User
    Body string
}

// IndexViewModel struct
type IndexViewModel struct {
    Title string
    User
    Posts []Post
}

就是 将 User User -> User其它都不变，这样执行，发现程序照常运行

不过，现在我们可以改下templates/index.html

templates.index.html

<html>
    <head>
        {{if .Title}}
            <title>{{.Title}} - blog</title>
        {{else}}
            <title>Welcome to blog!</title>
        {{end}}
    </head>
    <body>
        <h1>Hello, {{.Username}}!</h1>
        {{range .Posts}}
            <div><p>{{ .Username }} says: <b>{{ .Body }}</b></p></div>
        {{end}}

    </body>
</html>

由于 匿名组合 ，我们现在可以将 {{.User.Username}} -> {{.Username}}

就是我们可以直接使用 匿名组合 的属性，以及方法，其实也是变像的实现了继承。

关于 Go 的 面向对象，可以看下 参考

本小节 Diff

模板继承

其实 Go 的模板应该没有 Flask jinja2 这样的功能强大，它只有 include，所以为了实现模板的继承，我们需要发挥下主观能动性

index.html

package main

import (
    "html/template"
    "io/ioutil"
    "net/http"
    "os"
)

// User struct
type User struct {
    Username string
}

// Post struct
type Post struct {
    User
    Body string
}

// IndexViewModel struct
type IndexViewModel struct {
    Title string
    User
    Posts []Post
}

// PopulateTemplates func
// Create map template name to template.Template
func PopulateTemplates() map[string]*template.Template {
    const basePath = "templates"
    result := make(map[string]*template.Template)

    layout := template.Must(template.ParseFiles(basePath + "/_base.html"))
    dir, err := os.Open(basePath + "/content")
    if err != nil {
        panic("Failed to open template blocks directory: " + err.Error())
    }
    fis, err := dir.Readdir(-1)
    if err != nil {
        panic("Failed to read contents of content directory: " + err.Error())
    }
    for _, fi := range fis {
        f, err := os.Open(basePath + "/content/" + fi.Name())
        if err != nil {
            panic("Failed to open template '" + fi.Name() + "'")
        }
        content, err := ioutil.ReadAll(f)
        if err != nil {
            panic("Failed to read content from file '" + fi.Name() + "'")
        }
        f.Close()
        tmpl := template.Must(layout.Clone())
        _, err = tmpl.Parse(string(content))
        if err != nil {
            panic("Failed to parse contents of '" + fi.Name() + "' as template")
        }
        result[fi.Name()] = tmpl
    }
    return result
}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        u1 := User{Username: "bonfy"}
        u2 := User{Username: "rene"}

        posts := []Post{
            Post{User: u1, Body: "Beautiful day in Portland!"},
            Post{User: u2, Body: "The Avengers movie was so cool!"},
        }

        v := IndexViewModel{Title: "Homepage", User: u1, Posts: posts}

        templates := PopulateTemplates()
        templates["index.html"].Execute(w, &v)
    })
    http.ListenAndServe(":8888", nil)
}

templates/_base.html

<html>
    <head>
        {{if .Title}}
        <title>{{.Title}} - blog</title>
        {{else}}
        <title>Welcome to blog!</title>
        {{end}}
    </head>
    <body>
        <div>Blog: <a href="/">Home</a></div>
        {{template "content" .}}
    </body>
</html>

templates/content/index.html

{{define "content"}}
    <h1>Hello, {{.User.Username}}!</h1>

    {{range .Posts}}
        <div><p>{{ .User.Username }} says: <b>{{ .Body }}</b></p></div>
    {{end}}
{{end}}

这里用了模板继承，_base 是 基础模板，这样 比如 head 等信息不用再重复的去在每个.html文件中重复定义，我们可以专注于每个页面的业务逻辑和内容。

由于没有像 Jinja2 这样的原生支持模板继承，这个实现的关键就是 PopulateTemplates 函数，它的作用是 遍历 templates/content/ 文件夹下的所有文件，并和 templates/_base.html 合成 template.Template，然后再存入 map 中(在 Python 中一般叫 dict)，可以使用例如 index.html 的 key 来访问。

我们现在运行下程序，页面还是和原来一样(只是我们在 _base template 里面加入了 Home 的导航)，不过我们的templates文件夹已经有了基础模板，并且具备了快速扩展的能力。下章 Web Form 我们可以看见效果。

本小节 Diff

Links

目录

上一节: 02-Template-Basic

下一节: 04-Web-Form

04-Web Form

上两章我们讨论了Template，本章节我们继续讨论 Web 表单

Web表单是所有Web应用程序中最基本的组成部分之一。 本章我们将使用表单来为用户发表动态和登录认证提供途径。

本章的GitHub链接为 Source, Diff, Zip

结构优化

上一章我们通过 模板继承 以及 PopulateTemplates 对 templates 文件夹进行了梳理，在继续开始Web表单之前，我们再来梳理下其中 Go 代码的结构。

目前我们的所有的逻辑都集中在了 main.go 文件里，包括 model struct， viewmodel struct 定义，还有 handler 的实现，对于小点的web应用程序，可能这有助于快速开发，不过随着项目的扩大，代码量会变得越来越多，越来越臃肿，最后甚至会影响到阅读代码。这个时候结构的优化就显得尤为重要了。

我们的思路是 建立这样的数据结构:

package model - 负责数据建模（以及后一章 数据库 ORM）

package vm - 负责View Model

package controller - 负责 http 路由

每个文件夹下的 g.go 负责存放该package的全局变量 以及 init 函数。（ 只能说 类似 Python 的 __init__.py, 因为 Go 其实是通过大小写来表明是否可以外部引用, 不像Python，一定要 import 到 __init__.py 文件里才能通过 package 名来引用。)

我们先建立model 文件夹，然后将 Post、User struct 分别移到 model 文件夹下

model/user.go

package model

// User struct
type User struct {
    Username string
}

model/post.go

package model

// Post struct
type Post struct {
    User
    Body string
}

再将 view model 移到 vm文件夹下

vm/g.go

package vm

// BaseViewModel struct
type BaseViewModel struct {
    Title string
}

// SetTitle func
func (v *BaseViewModel) SetTitle(title string) {
    v.Title = title
}

由于_base.html 基础模板中有 Title 字段，所以 Title是每个view都必有的字段，我们将它单独设成个 BaseViewStruct，方便用 匿名组合；

vm/index.go

package vm

import "github.com/bonfy/go-mega-code/model"

// IndexViewModel struct
type IndexViewModel struct {
    BaseViewModel
    model.User
    Posts []model.Post
}

// IndexViewModelOp struct
type IndexViewModelOp struct{}

// GetVM func
func (IndexViewModelOp) GetVM() IndexViewModel {
    u1 := model.User{Username: "bonfy"}
    u2 := model.User{Username: "rene"}

    posts := []model.Post{
        model.Post{User: u1, Body: "Beautiful day in Portland!"},
        model.Post{User: u2, Body: "The Avengers movie was so cool!"},
    }

    v := IndexViewModel{BaseViewModel{Title: "Homepage"}, u1, posts}
    return v
}

将所有的路由相关移到controller

utils.go 存放 辅助工具函数，一般都是本package引用，所以小写就可以了, 这里PopulateTemplates 函数其实最好是小写，不过不去管它了。

controller/utils.go

package controller

import (
    "html/template"
    "io/ioutil"
    "os"
)

// PopulateTemplates func
// Create map template name to template.Template
func PopulateTemplates() map[string]*template.Template {
    const basePath = "templates"
    result := make(map[string]*template.Template)

    layout := template.Must(template.ParseFiles(basePath + "/_base.html"))
    dir, err := os.Open(basePath + "/content")
    if err != nil {
        panic("Failed to open template blocks directory: " + err.Error())
    }
    fis, err := dir.Readdir(-1)
    if err != nil {
        panic("Failed to read contents of content directory: " + err.Error())
    }
    for _, fi := range fis {
        f, err := os.Open(basePath + "/content/" + fi.Name())
        if err != nil {
            panic("Failed to open template '" + fi.Name() + "'")
        }
        content, err := ioutil.ReadAll(f)
        if err != nil {
            panic("Failed to read content from file '" + fi.Name() + "'")
        }
        f.Close()
        tmpl := template.Must(layout.Clone())
        _, err = tmpl.Parse(string(content))
        if err != nil {
            panic("Failed to parse contents of '" + fi.Name() + "' as template")
        }
        result[fi.Name()] = tmpl
    }
    return result
}

controller/g.go

package controller

import "html/template"

var (
    homeController home
    templates      map[string]*template.Template
)

func init() {
    templates = PopulateTemplates()
}

// Startup func
func Startup() {
    homeController.registerRoutes()
}

controller/home.go

package controller

import (
    "net/http"

    "github.com/bonfy/go-mega-code/vm"
)

type home struct{}

func (h home) registerRoutes() {
    http.HandleFunc("/", indexHandler)
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    vop := vm.IndexViewModelOp{}
    v := vop.GetVM()
    templates["index.html"].Execute(w, &v)
}

这里将 匿名函数 实名成 indexHandler，并将所有的构造 indexviewmodel 的逻辑全部移到了 vm/index.go 中的 GetVM 方法里

最终我们的结构优化成了下图的树状结构，这样有利于我们以后的扩展。

go-mega-code
├── controller
│   ├── g.go
│   ├── home.go
│   └── utils.go
├── main.go
├── model
│   ├── post.go
│   └── user.go
├── templates
│   ├── _base.html
│   └── content
│       └── index.html
└── vm
    ├── g.go
    └── index.go

本小节 Diff

用户登录表单

在将整个项目优化结构之后，我们建立登陆表单就非常简单了。

按照 index 的做法，login表单 我们其实需要，一个 template, 一个 vm, 以及一个 handler（其实后面基本上所有的加页面的做法也是类似）

templates/_base.html

...
<div>
    Blog:
    <a href="/">Home</a>
    <a href="/login">Login</a>
</div>
...

templates/content/login.html

{{define "content"}}
    <h1>Login</h1>
    <form action="/login" method="post" name="login">
        <p><input type="text" name="username" value="" placeholder="Username or Email"></p>
        <p><input type="password" name="password" value="" placeholder="Password"></p>
        <p><input type="submit" name="submit" value="Login"></p>
    </form>
{{end}}

login.html 还是继承 _base.html 只要关注 content 的内容就行了

vm/login.go

package vm

// LoginViewModel struct
type LoginViewModel struct {
    BaseViewModel
}

// LoginViewModelOp strutc
type LoginViewModelOp struct{}

// GetVM func
func (LoginViewModelOp) GetVM() LoginViewModel {
    v := LoginViewModel{}
    v.SetTitle("Login")
    return v
}

这里 v.SetTitle 就是用了 匿名组合 的特性，继承了 BaseViewModel 的 SetTitle 方法

controller/home.go 中加入 loginHandler

controller/home.go

func (h home) registerRoutes() {
    ...
    http.HandleFunc("/login", loginHandler)
}

...

func loginHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "login.html"
    vop := vm.LoginViewModelOp{}
    v := vop.GetVM()
    templates[tpName].Execute(w, &v)
}

...

此时，你可以验证结果了, 运行该应用，在浏览器的地址栏中输入 http://localhost:8888/然后点击顶部导航栏中的Login链接来查看新的登录表单。

本小节 Diff

接收表单数据

目前我们点击 Login 按钮，页面发现没有变化，其实我们后台还是接收到了这个请求，只是依然返回的是这个页面，接下来我们要对POST请求和GET请求分别做处理。

controller/home.go

...

func loginHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "login.html"
    vop := vm.IndexViewModelOp{}
    v := vop.GetVM()
    if r.Method == http.MethodGet {
        templates[tpName].Execute(w, &v)
    }
    if r.Method == http.MethodPost {
        r.ParseForm()
        username := r.Form.Get("username")
        password := r.Form.Get("password")
        fmt.Fprintf(w, "Username:%s Password:%s", username, password)
    }
}

Tip: html 中的form submit 是 Post 方法，简单说明下，不过相信大家都懂

修改 loginHandler 对 MethodGet 和 MethodPost 分别处理，MethodPost 接受 form post，运行后在login页面输入用户名、密码 点击Login，显示结果

本小节 Diff

表单后端验证

表单验证分为服务器前端验证 与 后端验证，比如验证 输入字符个数、正则匹配等，一般来说 用户名密码正确性检查只能在后端验证，其它前后端验证都可以，不过为了减少服务器压力与加强用户体验，字符长度等检查 一般放在前端做检查。

由于本教程主要是 Go 后端 web教程，这里简单的做一个 后端检查 的示例

LoginModelView里加入 Errs 字段，用于输出检查的错误返回

vm/login.go

...

type LoginViewModel struct {
	BaseViewModel
	Errs []string
}

// AddError func
func (v *LoginViewModel) AddError(errs ...string) {
	v.Errs = append(v.Errs, errs...)
}
...

login.html 中加入判断是否有错误，以及错误输出

templates/content/login.html

...
    {{if .Errs}}
    <ul>
        {{range .Errs}}
            <li>{{.}}</li>
         {{end}}
    </ul>
    {{end}}
...

controller/home.go

...

func check(username, password string) bool {
	if username == "bonfy" && password == "abc123" {
		return true
	}
	return false
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "login.html"
	vop := vm.LoginViewModelOp{}
	v := vop.GetVM()

	if r.Method == http.MethodGet {
		templates[tpName].Execute(w, &v)
	}
	if r.Method == http.MethodPost {
		r.ParseForm()
		username := r.Form.Get("username")
		password := r.Form.Get("password")

		if len(username) < 3 {
			v.AddError("username must longer than 3")
		}

		if len(password) < 6 {
			v.AddError("password must longer than 6")
		}

		if !check(username, password) {
			v.AddError("username password not correct, please input again")
		}

		if len(v.Errs) > 0 {
			templates[tpName].Execute(w, &v)
		} else {
			http.Redirect(w, r, "/", http.StatusSeeOther)
		}
	}
}

再次运行，在login页面随便输入用户名密码，如果不符合规范，就会有后端验证提示你重新输入。

本小节 Diff

Notice: 显示 Error , Flask-Mega 教程里面用的是 flash (flash-messages)，我们这边是将错误信息直接 render 到了页面上，其实也是可以用 flash 的，不过 Go 的 Session 目前没有特别好的第三方插件，我们现在还没有用到第三方包，就用了原生的渲染。后续集成Session之后会有例子用到flash。先在此说明下，耐心往下看。

Links

目录

上一节: 03-Template-Advance

下一节: 05-Database

05-Database

本章的主题是重中之重！大多数应用都需要持久化存储数据，并高效地执行的增删查改的操作，数据库 为此而生。

我们将第一次引入第三方库 Gorm 来帮助我们实现 ORM

本章的GitHub链接为： Source, Diff, Zip

数据库及ORM选择

数据库被划分为两大类，遵循关系模型的一类是 关系数据库，另外的则是 非关系数据库，简称 NoSQL，表现在它们不支持流行的关系查询语言SQL。这里我们打算使用的是 mysql, 这是一种非常流行的关系型数据库，也应该是应用最广泛的数据库了。

ORM我们选用Gorm, 应该是因为它的Star数遥遥领先吧。

首先我们利用下面的命令安装 Gorm

go get -u github.com/jinzhu/gorm

数据库模型

定义数据库中一张表及其字段的类，通常叫做数据模型。ORM (Gorm) 会将类的实例关联到数据库表中的数据行，并翻译相关操作

id字段通常存在于所有模型并用作主键。每个用户都会被数据库分配一个id值，并存储到这个字段中。大多数情况下，主键都是数据库自动赋值的，我只需要提供id字段作为主键即可。

user表: username，email和password_hash字段被定义为字符串（数据库术语中的VARCHAR），并指定其最大长度，以便数据库可以优化空间使用率。 username和email字段的用途不言而喻，password_hash字段值得提一下。 我想确保我正在构建的应用采用安全最佳实践，因此我不会将用户密码明文存储在数据库中。 明文存储密码的问题是，如果数据库被攻破，攻击者就会获得密码，这对用户隐私来说可能是毁灭性的。 如果使用哈希密码，这就大大提高了安全性。 这将是另一章的主题，所以现在不需分心。

post表: 将具有必须的id、用户动态的body和timestamp字段。 除了这些预期的字段之外，我还添加了一个user_id字段，将该用户动态链接到其作者。 你已经看到所有用户都有一个唯一的id主键， 将用户动态链接到其作者的方法是添加对用户id的引用，这正是user_id字段所在的位置。 这个user_id字段被称为外键。 上面的数据库图显示了外键作为该字段和它引用的表的id字段之间的链接。 这种关系被称为一对多，因为“一个”用户写了“多”条动态。

另外 我们后续还会有粉丝这种多对多的关系

model/user.go

package model

// User struct
type User struct {
    ID           int    `gorm:"primary_key"`
    Username     string `gorm:"type:varchar(64)"`
	Email        string `gorm:"type:varchar(120)"`
	PasswordHash string `gorm:"type:varchar(128)"`
    Posts        []Post
    Followers    []*User `gorm:"many2many:follower;association_jointable_foreignkey:follower_id"`
}

Notice: 此处应该是gorm:"type:varchar(64)" 当时写的Github源码有错，不过只影响建表时的varchar size，请注意

model/post.go

package model

import (
    "time"
)

// Post struct
type Post struct {
    ID        int `gorm:"primary_key"`
    UserID    int
    User      User
    Body      string     `gorm:"type:varchar(180)"`
    Timestamp *time.Time `sql:"DEFAULT:current_timestamp"`
}

model/g.go

package model

import (
    "log"

    "github.com/bonfy/go-mega-code/config"
    "github.com/jinzhu/gorm"
)

var db *gorm.DB

// SetDB func
func SetDB(database *gorm.DB) {
    db = database
}

// ConnectToDB func
func ConnectToDB() *gorm.DB {
    connectingStr := config.GetMysqlConnectingString()
    log.Println("Connet to db...")
    db, err := gorm.Open("mysql", connectingStr)
    if err != nil {
        panic("Failed to connect database")
    }
    db.SingularTable(true)
    return db
}

其实如果作为演示项目，我们可以将connectingStr := config.GetMysqlConnectingString() 直接写入实际的演示数据库地址，不过为了追求和实际项目中的将配置文件单独放置的效果，我们还是将实际项目中的方法演示给大家。

多引入一个第三方package github.com/spf13/viper

go get -u github.com/spf13/viper

设置 config 文件， config.yml, 可以放在项目目录下 (实际 Source Code 中 git ignore 了实际的 config.yml, 存放了 config.yml.sample)

config.yml

mysql:
  charset: utf8
  db: go-mega
  host: localhost
  password: password
  user: root

config/g.go

package config

import (
    "fmt"

    "github.com/spf13/viper"
)

func init() {
    projectName := "go-mega"
    getConfig(projectName)
}

func getConfig(projectName string) {
    viper.SetConfigName("config") // name of config file (without extension)

    viper.AddConfigPath(".")                                                // optionally look for config in the working directory
    viper.AddConfigPath(fmt.Sprintf("$HOME/.%s", projectName))              // call multiple times to add many search paths
    viper.AddConfigPath(fmt.Sprintf("/data/docker/config/%s", projectName)) // path to look for the config file in

    err := viper.ReadInConfig() // Find and read the config file
    if err != nil {             // Handle errors reading the config file
        panic(fmt.Errorf("Fatal error config file: %s", err))
    }
}

// GetMysqlConnectingString func
func GetMysqlConnectingString() string {
    usr := viper.GetString("mysql.user")
    pwd := viper.GetString("mysql.password")
    host := viper.GetString("mysql.host")
    db := viper.GetString("mysql.db")
    charset := viper.GetString("mysql.charset")

    return fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?charset=%s&parseTime=true", usr, pwd, host, db, charset)
}

由于 Go 是编译型语言，我们不能像 Python 那样可以逐行的调试演示，我们就直接编写个程序，初始化表

cmd/db_init/main.go

package main

import (
    "log"

    "github.com/bonfy/go-mega-code/model"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
    log.Println("DB Init ...")
    db := model.ConnectToDB()
    defer db.Close()
    model.SetDB(db)

    db.DropTableIfExists(model.User{}, model.Post{})
    db.CreateTable(model.User{}, model.Post{})
}

我们先在mysql中创建 go-mega 的数据库

$ mysql -u root -p
输入密码后
mysql> create DATABASE go-mega;

然后运行

go run cmd/db_init/main.go

本小节 Diff

初始化数据库数据

model/utils.go

package model

import (
    "crypto/md5"
    "encoding/hex"
)

// GeneratePasswordHash : Use MD5
func GeneratePasswordHash(pwd string) string {
    hasher := md5.New()
    hasher.Write([]byte(pwd))
    pwdHash := hex.EncodeToString(hasher.Sum(nil))
    return pwdHash
}

model/user.go

...
// SetPassword func: Set PasswordHash
func (u *User) SetPassword(password string) {
    u.PasswordHash = GeneratePasswordHash(password)
}

// CheckPassword func
func (u *User) CheckPassword(password string) bool {
    return GeneratePasswordHash(password) == u.PasswordHash
}

// GetUserByUsername func
func GetUserByUsername(username string) (*User, error) {
    var user User
    if err := db.Where("username=?", username).Find(&user).Error; err != nil {
        return nil, err
    }
    return &user, nil
}

model/post.go

// GetPostsByUserID func
func GetPostsByUserID(id int) (*[]Post, error) {
    var posts []Post
    if err := db.Preload("User").Where("user_id=?", id).Find(&posts).Error; err != nil {
        return nil, err
    }
    return &posts, nil
}

Notice: 特别注意这里的 Preload, 相当于预先的 Join Table，不然取得的 posts 就没有 User 信息

cmd/db_init/main.go

package main

import (
    "log"

    "github.com/bonfy/go-mega-code/model"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
    log.Println("DB Init ...")
    db := model.ConnectToDB()
    defer db.Close()
    model.SetDB(db)

    db.DropTableIfExists(model.User{}, model.Post{})
    db.CreateTable(model.User{}, model.Post{})

    users := []model.User{
        {
            Username:     "bonfy",
            PasswordHash: model.GeneratePasswordHash("abc123"),
            Posts: []model.Post{
                {Body: "Beautiful day in Portland!"},
            },
        },
        {
            Username:     "rene",
            PasswordHash: model.GeneratePasswordHash("abc123"),
            Email:        "rene@test.com",
            Posts: []model.Post{
                {Body: "The Avengers movie was so cool!"},
                {Body: "Sun shine is beautiful"},
            },
        },
    }

    for _, u := range users {
        db.Debug().Create(&u)
    }
}

运行

$ go run cmd/db_init/main.go

user表

post表

本小节 Diff

完善view

现在我们的数据库里有了数据，我们就可以直接从数据库中取得数据，生成 index 的 viewmodel 了

修改 vm/index.go 的 GetVM func， 从数据库中获得数据

vm/index.go

...
// GetVM func
func (IndexViewModelOp) GetVM() IndexViewModel {
    u1, _ := model.GetUserByUsername("rene")
    posts, _ := model.GetPostsByUserID(u1.ID)
    v := IndexViewModel{BaseViewModel{Title: "Homepage"}, *u1, *posts}
    return v
}

main.go

package main

import (
    "net/http"

    "github.com/bonfy/go-mega-code/controller"
    "github.com/bonfy/go-mega-code/model"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
    // Setup DB
    db := model.ConnectToDB()
    defer db.Close()
    model.SetDB(db)

    // Setup Controller
    controller.Startup()

    http.ListenAndServe(":8888", nil)
}

main.go 要初始化数据库连接，不然会报错。

另外 import _ "github.com/jinzhu/gorm/dialects/mysql" 确定你要使用的是 mysql 的 dialect

go run main.go

本小节 Diff

Notice: 由于 Gorm 的 auto migration 并不像 SQLAlchemy 那么强大，我们在使用的时候要十分小心，这里我就直接没有使用（一般项目会预先定义好数据结构，而且后期修改也可以通过SQL操作），如果有兴趣的同学可以去看下 Gorm 的文档，还是有部分的 Migration 的能力的。

Links

目录

上一节: 04-Web-Form

下一节: 06-User-Login

06-User Login

这章我将告诉你如何创建一个用户登录子系统。

你在第四章中学会了如何创建用户登录表单，在第五章中学会了运用数据库。本章将教你如何结合这两章的主题来创建一个简单的用户登录系统。

本章的GitHub链接为： Source, Diff, Zip

Session

不知道 Go 有没有第三方库实现类似 Flask-Login 这样的登陆辅助(我也没有好好去找)，不过我们知道原理，基本上就是用 Session 实现的，对 Session 不了解的同学可以看下 Session and Cookie

这里我们又要引入一个第三方package来实现这个Session(如果不引入第三方库，自己处理还满麻烦的，所以就选择easy way)

$ go get -v github.com/gorilla/sessions

全局变量设置

在 controller/g.go 中设置将要用到的全局变量 sessionName, store,

Tip: 设置全局变量其实是方便以后的更新，如果以后sessionName要改成 flask-mega,只要跑到全局设置的地方修改，不用到每个函数中修改，而且还容易改错。

store 初始化的时候可以设置 secret-key，这里直接 hard code 了，其实安全点的做法可以设置在配置文件里,这里就这样偷懒了吧

controller/g.go

package controller

import (
    "html/template"

    "github.com/gorilla/sessions"
)

var (
    homeController home
    templates      map[string]*template.Template
    sessionName    string
    store          *sessions.CookieStore
)

func init() {
    templates = PopulateTemplates()
    store = sessions.NewCookieStore([]byte("something-very-secret"))
    sessionName = "go-mega"
}

// Startup func
func Startup() {
    homeController.registerRoutes()
}

将 session 操作封装

操作函数基本上就是 GetSession, SetSession, ClearSession 所有语言说道Session基本上就实现这三个基本的，后面属于自由发挥

controller/utils.go

...

// session

func getSessionUser(r *http.Request) (string, error) {
    var username string
    session, err := store.Get(r, sessionName)
    if err != nil {
        return "", err
    }

    val := session.Values["user"]
    fmt.Println("val:", val)
    username, ok := val.(string)
    if !ok {
        return "", errors.New("can not get session user")
    }
    fmt.Println("username:", username)
    return username, nil
}

func setSessionUser(w http.ResponseWriter, r *http.Request, username string) error {
    session, err := store.Get(r, sessionName)
    if err != nil {
        return err
    }
    session.Values["user"] = username
    err = session.Save(r, w)
    if err != nil {
        return err
    }
    return nil
}

func clearSession(w http.ResponseWriter, r *http.Request) error {
    session, err := store.Get(r, sessionName)
    if err != nil {
        return err
    }

    session.Options.MaxAge = -1

    err = session.Save(r, w)
    if err != nil {
        return err
    }

    return nil
}

Tip: 这里 clearSession 的操作是通过设置 MaxAge 为 负数来完成的。

ListenAndServe 修改

main.go

    ...
    controller.Startup()

    http.ListenAndServe(":8888", context.ClearHandler(http.DefaultServeMux))

这样我们就支持了 session 我们在 controller home中来使用

vm/login.go

...

// CheckLogin func
func CheckLogin(username, password string) bool {
    user, err := model.GetUserByUsername(username)
    if err != nil {
        log.Println("Can not find username: ", username)
        log.Println("Error:", err)
        return false
    }
    return user.CheckPassword(password)
}

controller/home.go

...

func (h home) registerRoutes() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/logout", logoutHandler)
}

...

func loginHandler(w http.ResponseWriter, r *http.Request) {
    ...

        if !vm.CheckLogin(username, password) {
            v.AddError("username password not correct, please input again")
        }

        if len(v.Errs) > 0 {
            templates[tpName].Execute(w, &v)
        } else {
            setSessionUser(w, r, username)
            http.Redirect(w, r, "/", http.StatusSeeOther)
        }
    }
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    clearSession(w, r)
    http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
}

上面的代码完成了在 login 成功后设置 session，也加入了 logoutHanler

现在我们运行程序后，点击login 之后会发现，session go-mega

本小节 Diff

middleware实现登陆控制

一般正常的需要登陆的网站，如果你要访问主页面，如果你没有登陆过，跳转到登陆界面，如果之前登陆过，则直接跳转到你要访问的页面。

现在我们访问根目录http://127.0.0.1/是不受登陆控制的，如果我们要给它加上必须登陆后才能访问，要怎么处理呢？

答案就是加上 middleware 中间层去判断是否存在session，类似于 Python 中的装饰器的作用

controller/middle.go

package controller

import (
    "log"
    "net/http"
)

func middleAuth(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        username, err := getSessionUser(r)
        log.Println("middle:", username)
        if err != nil {
            log.Println("middle get session err and redirect to login")
            http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
        } else {
            next.ServeHTTP(w, r)
        }
    }
}

在路由上加入session 控制

...
func (h home) registerRoutes() {
    http.HandleFunc("/", middleAuth(indexHandler))
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/logout", middleAuth(logoutHandler))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "index.html"
    vop := vm.IndexViewModelOp{}
    username, _ := getSessionUser(r)
    v := vop.GetVM(username)
    templates[tpName].Execute(w, &v)
}
...

现在我们访问到 indexHandler 的时候 middleAuth 保证是有session的，所以取出session，根据取出的user来获取viewmodel响应的，我们的 vm 也要调整

主要是将 User区分下，比如 CurrentUser 以后就代表登陆的用户，也就是Session的User，例如ProfileUser 则可以是你查看的任何人

vm/g.go

package vm

// BaseViewModel struct
type BaseViewModel struct {
    Title       string
    CurrentUser string
}

// SetTitle func
func (v *BaseViewModel) SetTitle(title string) {
    v.Title = title
}

// SetCurrentUser func
func (v *BaseViewModel) SetCurrentUser(username string) {
    v.CurrentUser = username
}

vm/index.go

...
// GetVM func
func (IndexViewModelOp) GetVM(username string) IndexViewModel {
    u1, _ := model.GetUserByUsername(username)
    posts, _ := model.GetPostsByUserID(u1.ID)
    v := IndexViewModel{BaseViewModel{Title: "Homepage"}, *posts}
    v.SetCurrentUser(username)
    return v
}

templates/_base.html

    ...
        <div>
            Blog:
            <a href="/">Home</a>
            {{if .CurrentUser}}
            <a href="/logout">Logout</a>
            {{else}}
            <a href="/login">Login</a>
            {{end}}
        </div>
    ...

templates/content/index.html

{{define "content"}}
    <h1>Hello, {{.CurrentUser}}!</h1>

    {{range .Posts}}
        <div><p>{{ .User.Username }} says: <b>{{ .Body }}</b></p></div>
    {{end}}
{{end}}

然后运行，输入 http://127.0.0.1/ 会直接跳转到登陆页面；在正确输入用户名、密码正确登陆后，右上角变成了 Logout

本小节 Diff

加入register

templates/content/register.html

{{define "content"}}
    <h1>Register</h1>
    <form action="/register" method="post" name="register">
        <p><input type="text" name="username" value="" placeholder="Username"></p>
        <p><input type="text" name="email" value="" placeholder="Email"></p>
        <p><input type="password" name="pwd1" value="" placeholder="Password"></p>
        <p><input type="password" name="pwd2" value="" placeholder="Password"></p>
        <p><input type="submit" name="submit" value="Register"></p>
    </form>

    <p>Have account? <a href="/login">Click to Login!</a></p>

    {{if .Errs}}
    <ul>
        {{range .Errs}}
            <li>{{.}}</li>
         {{end}}
    </ul>
    {{end}}
{{end}}

在 login 的页面上加入 register 链接

templates/content/login.html

...
 <p>New User? <a href="/register">Click to Register!</a></p>
...

addUser 的调用是 vm调用model的，controller 调用 vm的，所以各层都要建立 AddUser函数

model/user.go

...
// AddUser func
func AddUser(username, password, email string) error {
    user := User{Username: username, Email: email}
    user.SetPassword(password)
    return db.Create(&user).Error
}

vm/register.go

package vm

import (
    "log"

    "github.com/bonfy/go-mega-code/model"
)

// RegisterViewModel struct
type RegisterViewModel struct {
    LoginViewModel
}

// RegisterViewModelOp struct
type RegisterViewModelOp struct{}

// GetVM func
func (RegisterViewModelOp) GetVM() RegisterViewModel {
    v := RegisterViewModel{}
    v.SetTitle("Register")
    return v
}

// CheckUserExist func
func CheckUserExist(username string) bool {
    _, err := model.GetUserByUsername(username)
    if err != nil {
        log.Println("Can not find username: ", username)
        return true
    }
    return false
}

// AddUser func
func AddUser(username, password, email string) error {
    return model.AddUser(username, password, email)
}

controller/utils.go

...
// Login Check
func checkLen(fieldName, fieldValue string, minLen, maxLen int) string {
    lenField := len(fieldValue)
    if lenField < minLen {
        return fmt.Sprintf("%s field is too short, less than %d", fieldName, minLen)
    }
    if lenField > maxLen {
        return fmt.Sprintf("%s field is too long, more than %d", fieldName, maxLen)
    }
    return ""
}

func checkUsername(username string) string {
    return checkLen("Username", username, 3, 20)
}

func checkPassword(password string) string {
    return checkLen("Password", password, 6, 50)
}

func checkEmail(email string) string {
    if m, _ := regexp.MatchString(`^([\w\.\_]{2,10})@(\w{1,}).([a-z]{2,4})$`, email); !m {
        return fmt.Sprintf("Email field not a valid email")
    }
    return ""
}

func checkUserPassword(username, password string) string {
    if !vm.CheckLogin(username, password) {
        return fmt.Sprintf("Username and password is not correct.")
    }
    return ""
}

func checkUserExist(username string) string {
    if !vm.CheckUserExist(username) {
        return fmt.Sprintf("Username already exist, please choose another username")
    }
    return ""
}

// checkLogin()
func checkLogin(username, password string) []string {
    var errs []string
    if errCheck := checkUsername(username); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    if errCheck := checkPassword(password); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    if errCheck := checkUserPassword(username, password); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    return errs
}

// checkRegister()
func checkRegister(username, email, pwd1, pwd2 string) []string {
    var errs []string
    if pwd1 != pwd2 {
        errs = append(errs, "2 password does not match")
    }
    if errCheck := checkUsername(username); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    if errCheck := checkPassword(pwd1); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    if errCheck := checkEmail(email); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    if errCheck := checkUserExist(username); len(errCheck) > 0 {
        errs = append(errs, errCheck)
    }
    return errs
}

// addUser()
func addUser(username, password, email string) error {
    return vm.AddUser(username, password, email)
}

我们顺便将各种后端验证（check function） 都移到了 controller/utils.go，这样便于以后的扩展,而且 controller/home.go 也相应简化了

controller/home.go

package controller

import (
    "log"
    "net/http"

    "github.com/bonfy/go-mega-code/vm"
)

type home struct{}

func (h home) registerRoutes() {
    http.HandleFunc("/logout", middleAuth(logoutHandler))
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/register", registerHandler)
    http.HandleFunc("/", middleAuth(indexHandler))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "index.html"
    vop := vm.IndexViewModelOp{}
    username, _ := getSessionUser(r)
    v := vop.GetVM(username)
    templates[tpName].Execute(w, &v)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "login.html"
    vop := vm.LoginViewModelOp{}
    v := vop.GetVM()

    if r.Method == http.MethodGet {
        templates[tpName].Execute(w, &v)
    }
    if r.Method == http.MethodPost {
        r.ParseForm()
        username := r.Form.Get("username")
        password := r.Form.Get("password")

        errs := checkLogin(username, password)
        v.AddError(errs...)

        if len(v.Errs) > 0 {
            templates[tpName].Execute(w, &v)
        } else {
            setSessionUser(w, r, username)
            http.Redirect(w, r, "/", http.StatusSeeOther)
        }
    }
}

func registerHandler(w http.ResponseWriter, r *http.Request) {
    tpName := "register.html"
    vop := vm.RegisterViewModelOp{}
    v := vop.GetVM()

    if r.Method == http.MethodGet {
        templates[tpName].Execute(w, &v)
    }
    if r.Method == http.MethodPost {
        r.ParseForm()
        username := r.Form.Get("username")
        email := r.Form.Get("email")
        pwd1 := r.Form.Get("pwd1")
        pwd2 := r.Form.Get("pwd2")

        errs := checkRegister(username, email, pwd1, pwd2)
        v.AddError(errs...)

        if len(v.Errs) > 0 {
            templates[tpName].Execute(w, &v)
        } else {
            if err := addUser(username, pwd1, email); err != nil {
                log.Println("add User error:", err)
                w.Write([]byte("Error insert database"))
                return
            }
            setSessionUser(w, r, username)
            http.Redirect(w, r, "/", http.StatusSeeOther)
        }
    }
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    clearSession(w, r)
    http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
}

运行程序，我们就有了注册页面了

本小节 Diff

Links

目录

上一节: 05-Database

下一节: 07-Profile-Page-And-Avatar

07-Profile Page and Avatar

本章将致力于为应用添加个人主页。个人主页用来展示用户的相关信息，其个人信息由本人录入。 我将为你展示如何动态地生成每个用户的主页，并提供一个编辑页面给他们来更新个人信息。

本章的GitHub链接为： Source, Diff, Zip

Profile Page

作为创建个人主页的第一步，让我们为其URL /user/ 新建一个对应的视图函数。

我们还是老套路 一个 vm 加一个 page 另外再加一个 controller （model 暂时不涉及新的model）

vm/profile.go

package vm

import "github.com/bonfy/go-mega-code/model"

// ProfileViewModel struct
type ProfileViewModel struct {
	BaseViewModel
	Posts       []model.Post
	ProfileUser model.User
}

// ProfileViewModelOp struct
type ProfileViewModelOp struct{}

// GetVM func
func (ProfileViewModelOp) GetVM(sUser, pUser string) (ProfileViewModel, error) {
	v := ProfileViewModel{}
	v.SetTitle("Profile")
	u1, err := model.GetUserByUsername(pUser)
	if err != nil {
		return v, err
	}
	posts, _ := model.GetPostsByUserID(u1.ID)
	v.ProfileUser = *u1
	v.Posts = *posts
	v.SetCurrentUser(sUser)
	return v, nil
}

_base.html 在登陆情况下 加入 Profile 的链接

templates/_base.html

...
    <div>
        Blog:
        <a href="/">Home</a>
        {{if .CurrentUser}}
        <a href="/user/{{.CurrentUser}}">Profile</a>
        <a href="/logout">Logout</a>
        {{else}}
        <a href="/login">Login</a>
        {{end}}
    </div>
...

templates/content/profile.html

{{define "content"}}
    <h1>User: {{.ProfileUser.Username}}</h1>

    <hr/>

    {{range .Posts}}
    <p>
    {{ .User.Username }} says: <b>{{ .Body }}</b>
    </p>
    {{end}}
{{end}}

加入 profileController

controller/home.go

package controller

import (
	"fmt"
	"log"
	"net/http"

	"github.com/bonfy/go-mega-code/vm"
	"github.com/gorilla/mux"
)

type home struct{}

func (h home) registerRoutes() {
	r := mux.NewRouter()
	r.HandleFunc("/logout", middleAuth(logoutHandler))
	r.HandleFunc("/login", loginHandler)
	r.HandleFunc("/register", registerHandler)
	r.HandleFunc("/user/{username}", middleAuth(profileHandler))
	r.HandleFunc("/", middleAuth(indexHandler))

	http.Handle("/", r)
}

...

func profileHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "profile.html"
	vars := mux.Vars(r)
	pUser := vars["username"]
	sUser, _ := getSessionUser(r)
	vop := vm.ProfileViewModelOp{}
	v, err := vop.GetVM(sUser, pUser)
	if err != nil {
		msg := fmt.Sprintf("user ( %s ) does not exist", pUser)
		w.Write([]byte(msg))
		return
	}
	templates[tpName].Execute(w, &v)
}

...

这里面由于要实现 flask 那样的 /user/username 的效果，快速的方法是引入 gorilla/mux 的第三方package

$ go get -v github.com/gorilla/mux

然后注意 registerRoutes 函数，里面原来的 http.handleFunc 全部替换成 r.handleFunc 交给第三方的 gorilla/mux来处理，这样我们就可以在 handler里面使用 mux.Vars 来解析URL里面的{username}了

另外 严谨起见，这里在 profileHandler 前面加了 auth check,其实不加的话也是OK的，区别就是在登陆之前能不能查看特定 user 的 Profile

我们运行程序，登陆后点击 Profile的链接，就能查看到结果了。

当然你在地址栏里面直接输入 http://127.0.0.1/user/username如果存在，就显示 User 的 Profile,不存在就提示 user (username) does not exist

本小节 Diff

Avatar

我相信你也觉得我刚刚建立的个人主页非常枯燥乏味。为了使它们更加有趣，我将添加用户头像。与其在服务器上处理大量的上传图片，我将使用Gravatar为所有用户提供图片服务。

Gravatar服务使用起来非常简单。 要请求给定用户的图片，使用格式为https://www.gravatar.com/avatar/ 的URL即可，其中 hash 是用户的电子邮件地址的MD5哈希值。 在下面，你可以看到如何生成电子邮件为john@example.com的用户的Gravatar URL：

如果你想看一个实际的例子，我自己的Gravatar URL是https://www.gravatar.com/avatar/c60f4fa4bf54012b80bc140aab0fc2bc。Gravatar返回的图片如下：

默认情况下 是 80*80 , 但可以通过向URL的查询字符串添加s参数来请求不同大小的图片。如: https://www.gravatar.com/avatar/c60f4fa4bf54012b80bc140aab0fc2bc?s=128

另一个可传递给Gravatar的有趣参数是d，它让Gravatar为没有向服务注册头像的用户提供的随机头像。 我最喜欢的随机头像类型是“identicon”，它为每个邮箱都返回一个漂亮且不重复的几何设计图片。 如下：

请注意，一些Web浏览器插件（如Ghostery）会屏蔽Gravatar图像，因为它们认为Automattic（Gravatar服务的所有者）可以根据你发送的获取头像的请求来判断你正在访问的网站。 如果在浏览器中看不到头像，你在排查问题的时候可以考虑以下是否在浏览器中安装了此类插件。

由于头像与用户相关联，所以将生成头像URL的逻辑添加到用户模型是有道理的。

如果你对Gravatar服务很有兴趣，可以学习他们的文档。

理论说好了，我们来写代码，由于 Go 原生的Template 不像 Jinja2 那么好支持函数（ Go Template 是支持自定义函数的，只是要在 template.New().Funcs() 中预先传入，与我们已有的 PopulateTemplates函数集成上有点难度）

Notice: Go Template 支持类的 Func，不用预先传入，这里 Avatar 字段不是特别的必要了，特此说明，可以参见 12-Dates-And-Times的用法

所以这里发挥主观能动性，直接将 Avatar 作为字段放入数据库中，等于冗余了Avatar数据，但是减少了我们coding的难度（我们也乘此机会，fix下上次提到的Gorm format问题）

虽然我们 GeneratePasswordHash 就是 MD5 方法，不过为了逻辑的清晰，我们可以像下面代码这样处理。

model/utils.go

package model

import (
	"crypto/md5"
	"encoding/hex"
)

// GeneratePasswordHash : Use MD5
func GeneratePasswordHash(pwd string) string {
	return Md5(pwd)
}

// Md5 func
func Md5(origin string) string {
	hasher := md5.New()
	hasher.Write([]byte(origin))
	return hex.EncodeToString(hasher.Sum(nil))
}

model/user.go

...
// 说明: User 加入 Avatar、AboutMe、LastSeen 字段
type User struct {
	ID           int    `gorm:"primary_key"`
	Username     string `gorm:"type:varchar(64)"`
	Email        string `gorm:"type:varchar(120)"`
	PasswordHash string `gorm:"type:varchar(128)"`
	LastSeen     *time.Time
	AboutMe      string `gorm:"type:varchar(140)"`
	Avatar       string `gorm:"type:varchar(200)"`
	Posts        []Post
	Followers    []*User `gorm:"many2many:follower;association_jointable_foreignkey:follower_id"`
}
...

// 说明: 在增加User 的时候，直接设置Avatar

// SetAvatar func
func (u *User) SetAvatar(email string) {
	u.Avatar = fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon", Md5(email))
}

// AddUser func
func AddUser(username, password, email string) error {
	user := User{Username: username, Email: email}
	user.SetPassword(password)
	user.SetAvatar(email)
	return db.Create(&user).Error
}
...

cmd/db_init/main.go

package main

import (
	"fmt"
	"log"

	"github.com/bonfy/go-mega-code/model"
	_ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
	log.Println("DB Init ...")
	db := model.ConnectToDB()
	defer db.Close()
	model.SetDB(db)

	db.DropTableIfExists(model.User{}, model.Post{})
	db.CreateTable(model.User{}, model.Post{})

	users := []model.User{
		{
			Username:     "bonfy",
			PasswordHash: model.GeneratePasswordHash("abc123"),
			Email:        "i@bonfy.im",
			Avatar:       fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon", model.Md5("i@bonfy.im")),
			Posts: []model.Post{
				{Body: "Beautiful day in Portland!"},
			},
		},
		{
			Username:     "rene",
			PasswordHash: model.GeneratePasswordHash("abc123"),
			Email:        "rene@test.com",
			Avatar:       fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon", model.Md5("rene@test.com")),
			Posts: []model.Post{
				{Body: "The Avengers movie was so cool!"},
				{Body: "Sun shine is beautiful"},
			},
		},
	}

	for _, u := range users {
		db.Debug().Create(&u)
	}
}

这样再运行

$ go run cmd/db_init/main.go

数据库中的数据就有了 Avatar 这个字段

现在我们再在 profile.html加入Avatar

这样我的个人主页的顶部有一个不错的大头像，不止如此，底下的所有用户动态都会有一个小头像。

templates/content/profile.html

{{define "content"}}

    <table>
        <tr valign="top">
            <td><img src="{{.ProfileUser.Avatar}}&s=128"></td>
            <td><h1>User: {{.ProfileUser.Username}}</h1></td>
        </tr>
    </table>

    <hr/>

    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td>{{ .User.Username }} says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}

{{end}}

运行程序

$ go run main.go

本小节 Diff

More Info

新增的个人主页存在的一个问题是，真正显示的内容不够丰富。 用户喜欢在个人主页上展示他们的相关信息，所以我会让他们写一些自我介绍并在这里展示。 我也将跟踪每个用户最后一次访问该网站的时间，并显示在他们的个人主页上。

新字段 last_seen 和 about_me 已经在前面一起创建过了，我们只要在页面中加入就行了。

现在 model/user.go 中加入 UpdateLastSeen 函数

model/user.go

...

// UpdateUserByUsername func
func UpdateUserByUsername(username string, contents map[string]interface{}) error {
	item, err := GetUserByUsername(username)
	if err != nil {
		return err
	}
	return db.Model(item).Updates(contents).Error
}

// UpdateLastSeen func
func UpdateLastSeen(username string) error {
	contents := map[string]interface{}{"last_seen": time.Now()}
	return UpdateUserByUsername(username, contents)
}

在 middleAuth 中，如果判断用户登陆了，就更新他的 LastSeen 时间 (middleAuth的用法类似于Python 里面的装饰器用法)

Tip: 我们一般是不会在 controller 层直接和 model 层打交道，一般会通过 vm 层去处理，但是由于 middle 层不具有具体的view，我们这里破例直接调用 model 中的方法。 当然考究点，你可以建立一个 middle 的vm，在里面新建一个 UpdateLastSeen 再在 controller/middle 中调用，也是可以的

controller/middle.go

package controller

import (
	"log"
	"net/http"

	"github.com/bonfy/go-mega-code/model"
)

func middleAuth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		username, err := getSessionUser(r)
		log.Println("middle:", username)
		if username != "" {
			log.Println("Last seen:", username)
			model.UpdateLastSeen(username)
		}
		if err != nil {
			log.Println("middle get session err and redirect to login")
			http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
		} else {
			next.ServeHTTP(w, r)
		}
	}
}

...

在 profile.html 中加入 AboutMe 以及 LastSeen

templates/content/profile.html

{{define "content"}}

    <table>
        <tr valign="top">
            <td><img src="{{.ProfileUser.Avatar}}&s=128"></td>
            <td>
                <h1>User: {{.ProfileUser.Username}}</h1>
                {{if .ProfileUser.AboutMe}}
                    <p>{{ .ProfileUser.AboutMe }}</p>
                {{end}}

                {{if .ProfileUser.LastSeen}}
                    <p>Last seen on: {{ .ProfileUser.LastSeen }}</p>
                {{end}}
            </td>
        </tr>
    </table>

    <hr/>

    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td>{{ .User.Username }} says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}

{{end}}

本小节 Diff

Edit Profile

我还需要给用户一个表单，让他们输入一些个人资料。 表单将允许用户更改他们的用户名，并且写一些个人介绍，以存储在新的about_me字段中。

其实细分的话分两步：

在Profile页面加入 Edit 的链接

增加一个 profile_edit 的页面

加入 Edit 链接: vm 增加 Editable 字段， 然后在 profile.html 中加入链接

model/profile.go

package vm

import "github.com/bonfy/go-mega-code/model"

// ProfileViewModel struct
type ProfileViewModel struct {
	BaseViewModel
	Posts       []model.Post
	Editable    bool
	ProfileUser model.User
}

// ProfileViewModelOp struct
type ProfileViewModelOp struct{}

// GetVM func
func (ProfileViewModelOp) GetVM(sUser, pUser string) (ProfileViewModel, error) {
	v := ProfileViewModel{}
	v.SetTitle("Profile")
	u1, err := model.GetUserByUsername(pUser)
	if err != nil {
		return v, err
	}
	posts, _ := model.GetPostsByUserID(u1.ID)
	v.ProfileUser = *u1
	v.Editable = (sUser == pUser)
	v.Posts = *posts
	v.SetCurrentUser(sUser)
	return v, nil
}

templates/content/profile.html

{{define "content"}}

    <table>
        <tr valign="top">
            <td><img src="{{.ProfileUser.Avatar}}&s=128"></td>
            <td>
                <h1>User: {{.ProfileUser.Username}}</h1>
                {{if .ProfileUser.AboutMe}}
                    <p><pre>{{ .ProfileUser.AboutMe }}</pre></p>
                {{end}}

                {{if .ProfileUser.LastSeen}}
                    <p>Last seen on: {{ .ProfileUser.LastSeen }}</p>
                {{end}}

                {{if .Editable}}
                    <p><a href="/profile_edit">Edit your profile</a></p>
                {{end}}
            </td>
        </tr>
    </table>

    <hr/>
...

增加 profile_edit ，这个老套路

model/user.go

...

// UpdateAboutMe func
func UpdateAboutMe(username, text string) error {
	contents := map[string]interface{}{"about_me": text}
	return UpdateUserByUsername(username, contents)
}

vm/profile_edit.go

package vm

import "github.com/bonfy/go-mega-code/model"

// ProfileEditViewModel struct
type ProfileEditViewModel struct {
	LoginViewModel
	ProfileUser model.User
}

// ProfileEditViewModelOp struct
type ProfileEditViewModelOp struct{}

// GetVM func
func (ProfileEditViewModelOp) GetVM(username string) ProfileEditViewModel {

	v := ProfileEditViewModel{}
	u, _ := model.GetUserByUsername(username)
	v.SetTitle("Profile Edit")
	v.SetCurrentUser(username)
	v.ProfileUser = *u
	return v
}

// UpdateAboutMe func
func UpdateAboutMe(username, text string) error {
	return model.UpdateAboutMe(username, text)
}

templates/content/profile_edit.html

{{define "content"}}
    <h1>Profile Edit</h1>
    <p>Username: {{.ProfileUser.Username}}</p>
    <form action="/profile_edit" method="post" name="profile_edit">
        <p>About Me</p>
        <p><textarea  name="aboutme" rows="5" cols="80" value="" placeholder="about me">{{.ProfileUser.AboutMe}}</textarea></p>
        <p><input type="submit" name="submit" value="Save"></p>
    </form>

    {{if .Errs}}
    <ul>
        {{range .Errs}}
            <li>{{.}}</li>
         {{end}}
    </ul>
    {{end}}
{{end}}

最后在 controller 里面加入 profileEditHandler

controller/home.go

...

func (h home) registerRoutes() {
	r := mux.NewRouter()
	r.HandleFunc("/logout", middleAuth(logoutHandler))
	r.HandleFunc("/login", loginHandler)
	r.HandleFunc("/register", registerHandler)
	r.HandleFunc("/user/{username}", middleAuth(profileHandler))
	r.HandleFunc("/profile_edit", middleAuth(profileEditHandler))
	r.HandleFunc("/", middleAuth(indexHandler))

	http.Handle("/", r)
}

...

func profileEditHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "profile_edit.html"
	username, _ := getSessionUser(r)
	vop := vm.ProfileEditViewModelOp{}
	v := vop.GetVM(username)
	if r.Method == http.MethodGet {
		err := templates[tpName].Execute(w, &v)
		if err != nil {
			log.Println(err)
		}
	}

	if r.Method == http.MethodPost {
		r.ParseForm()
		aboutme := r.Form.Get("aboutme")
		log.Println(aboutme)
		if err := vm.UpdateAboutMe(username, aboutme); err != nil {
			log.Println("update Aboutme error:", err)
			w.Write([]byte("Error update aboutme"))
			return
		}
		http.Redirect(w, r, fmt.Sprintf("/user/%s", username), http.StatusSeeOther)
	}
}

本小节 Diff

Links

目录

上一节: 06-User-Login

下一节: 08-Follower

08-Follower

这章将告诉你如何实现类似于Twitter和其他社交网络的“粉丝”功能。

在本章中，我将更多地使用应用的数据库。 我希望应用的用户能够轻松便捷地关注其他用户。

本章的GitHub链接为： Source, Diff, Zip

完善用户模型

首先我们列下要实现的用户操作:

关注 - Follow

取消关注 - UnFollow

关注自己 - FollowSelf

follower数量 - FollowersCount

following数量 - FollowingCount

关注的Posts - FollowingPosts 这个将被用在页面美化那章中的登陆后的IndexView显示

判断是否被关注 - IsFollowedByUser

(*)创建Post - CreatePost 这个是我们为了方便创建数据加入的，和粉丝操作关系不大

Tip: 这里 Followers 可以通过 Gorm 的 Association("Followers")来实现， Following 好像不支持(不确定，看了几遍文档没有找到)，不过这里我们一顿骚操作，自己来实现一下嘛

model/user.go

...
// Follow func
// follow someone usr_id other.id follow_id u.id
func (u *User) Follow(username string) error {
	other, err := GetUserByUsername(username)
	if err != nil {
		return err
	}
	return db.Model(other).Association("Followers").Append(u).Error
}

// Unfollow func
func (u *User) Unfollow(username string) error {
	other, err := GetUserByUsername(username)
	if err != nil {
		return err
	}
	return db.Model(other).Association("Followers").Delete(u).Error
}

// FollowSelf func
func (u *User) FollowSelf() error {
	return db.Model(u).Association("Followers").Append(u).Error
}

// FollowersCount func
func (u *User) FollowersCount() int {
	return db.Model(u).Association("Followers").Count()
}

// FollowingIDs func
func (u *User) FollowingIDs() []int {
	var ids []int
	rows, err := db.Table("follower").Where("follower_id = ?", u.ID).Select("user_id, follower_id").Rows()
	if err != nil {
		log.Println("Counting Following error:", err)
		return ids
	}
	defer rows.Close()
	for rows.Next() {
		var id, followerID int
		rows.Scan(&id, &followerID)
		ids = append(ids, id)
	}
	return ids
}

// FollowingCount func
func (u *User) FollowingCount() int {
	ids := u.FollowingIDs()
	return len(ids)
}

// FollowingPosts func
func (u *User) FollowingPosts() (*[]Post, error) {
	var posts []Post
	ids := u.FollowingIDs()
	if err := db.Preload("User").Order("timestamp desc").Where("user_id in (?)", ids).Find(&posts).Error; err != nil {
		return nil, err
	}
	return &posts, nil
}

// IsFollowedByUser func
func (u *User) IsFollowedByUser(username string) bool {
	user, _ := GetUserByUsername(username)
	ids := user.FollowingIDs()
	for _, id := range ids {
		if u.ID == id {
			return true
		}
	}
	return false
}

// CreatePost func
func (u *User) CreatePost(body string) error {
	post := Post{Body: body, UserID: u.ID}
	return db.Create(&post).Error
}
...

然后在 AddUser 的时候加入 FollowSelf, 即自己关注自己，方便在显示 Profile 页的时候，展示 FollowingPosts 将自己的 Post 也列进去

model/user.go

// AddUser func
func AddUser(username, password, email string) error {
	user := User{Username: username, Email: email}
	user.SetPassword(password)
	user.SetAvatar(email)
	if err := db.Create(&user).Error; err != nil {
		return err
	}
	return user.FollowSelf()
}

由于我们一开始的建立用户，没有加入 Follower 功能，我们重新初始化一遍数据

cmd/init_db\main.go

package main

import (
	"log"

	"github.com/bonfy/go-mega-code/model"
	_ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
	log.Println("DB Init ...")
	db := model.ConnectToDB()
	defer db.Close()
	model.SetDB(db)

	db.DropTableIfExists(model.User{}, model.Post{}, "follower")
	db.CreateTable(model.User{}, model.Post{})

	model.AddUser("bonfy", "abc123", "i@bonfy.im")
	model.AddUser("rene", "abc123", "rene@test.com")

	u1, _ := model.GetUserByUsername("bonfy")
	u1.CreatePost("Beautiful day in Portland!")
	model.UpdateAboutMe(u1.Username, `I'm the author of Go-Mega Tutorial you are reading now!`)

	u2, _ := model.GetUserByUsername("rene")
	u2.CreatePost("The Avengers movie was so cool!")
	u2.CreatePost("Sun shine is beautiful")

	u1.Follow(u2.Username)
}

执行

$ go run cmd/db_init/main.go

然后查看 follower 表，里面就有数据了

说明: 如上图 user_id 表示用户， follower_id 表示关注者， bonfy（id:1) 关注者 只有自己, 而 rene(id:2) 关注者 有两位, 这也是 u1.Follow(u2.Username) 的执行结果

本小节 Diff

完善页面显示

增加 Follow and Unfollow 操作

controller/home.go

...

func (h home) registerRoutes() {
	r := mux.NewRouter()
	r.HandleFunc("/logout", middleAuth(logoutHandler))
	r.HandleFunc("/login", loginHandler)
	r.HandleFunc("/register", registerHandler)
	r.HandleFunc("/user/{username}", middleAuth(profileHandler))
	r.HandleFunc("/follow/{username}", middleAuth(followHandler))
	r.HandleFunc("/unfollow/{username}", middleAuth(unFollowHandler))
	r.HandleFunc("/profile_edit", middleAuth(profileEditHandler))
	r.HandleFunc("/", middleAuth(indexHandler))

	http.Handle("/", r)
}

...

func followHandler(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	pUser := vars["username"]
	sUser, _ := getSessionUser(r)

	err := vm.Follow(sUser, pUser)
	if err != nil {
		log.Println("Follow error:", err)
		w.Write([]byte("Error in Follow"))
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/user/%s", pUser), http.StatusSeeOther)
}

func unFollowHandler(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	pUser := vars["username"]
	sUser, _ := getSessionUser(r)

	err := vm.UnFollow(sUser, pUser)
	if err != nil {
		log.Println("UnFollow error:", err)
		w.Write([]byte("Error in UnFollow"))
		return
	}
	http.Redirect(w, r, fmt.Sprintf("/user/%s", pUser), http.StatusSeeOther)
}

Follow 和 Unfollow之后通过 Redirect 回到原来的页面

完善Profile页面

vm/profile.go

package vm

import "github.com/bonfy/go-mega-code/model"

// ProfileViewModel struct
type ProfileViewModel struct {
	BaseViewModel
	Posts          []model.Post
	Editable       bool
	IsFollow       bool
	FollowersCount int
	FollowingCount int
	ProfileUser    model.User
}

// ProfileViewModelOp struct
type ProfileViewModelOp struct{}

// GetVM func
func (ProfileViewModelOp) GetVM(sUser, pUser string) (ProfileViewModel, error) {
	v := ProfileViewModel{}
	v.SetTitle("Profile")
	u, err := model.GetUserByUsername(pUser)
	if err != nil {
		return v, err
	}
	posts, _ := model.GetPostsByUserID(u.ID)
	v.ProfileUser = *u
	v.Editable = (sUser == pUser)

	if !v.Editable {
		v.IsFollow = u.IsFollowedByUser(sUser)
	}
	v.FollowersCount = u.FollowersCount()
	v.FollowingCount = u.FollowingCount()

	v.Posts = *posts
	v.SetCurrentUser(sUser)
	return v, nil
}

// Follow func : A follow B
func Follow(a, b string) error {
	u, err := model.GetUserByUsername(a)
	if err != nil {
		return err
	}
	return u.Follow(b)
}

// UnFollow func : A unfollow B
func UnFollow(a, b string) error {
	u, err := model.GetUserByUsername(a)
	if err != nil {
		return err
	}
	return u.Unfollow(b)
}

templates/content/profile.html

...

	{{if .ProfileUser.LastSeen}}
		<p>Last seen on: {{ .ProfileUser.LastSeen }}</p>
	{{end}}

	<p>{{ .FollowersCount }} followers, {{ .FollowingCount }} following.</p>

	{{if .Editable}}
		<p><a href="/profile_edit">Edit your profile</a></p>
	{{else}}
		{{if .IsFollow}}
			<p><a href="/unfollow/{{.ProfileUser.Username}}">Unfollow</a></p>
		{{else}}
			<p><a href="/follow/{{.ProfileUser.Username}}">Follow</a></p>
		{{end}}
	{{end}}
...

运行

$ go run main.go

说明: 这个是通过 rene 登陆之后访问 http://127.0.0.1:8888/user/bonfy 查看 bonfy 的profile, 由于 rene 没有 follow bonfy，可以点击 Follow 按钮，实现Follow操作

本小节 Diff

Links

目录

上一节: 07-Profile-Page-And-Avatar

下一节: 09-Pagination

09-Pagination

在本章，我将告诉你如何对数据列表进行分页。

在第八章我们支持了社交网络非常流行的“粉丝”机制。 有了这个功能，接下来我准备好删除一开始就使用的模拟用户动态了。 在本章中，应用将开始接受来自用户的动态更新，并将其发布到网站首页和个人主页。

本章的GitHub链接为： Source, Diff, Zip

发布用户动态

简言之，就是发布Post，首页需要有一个表单，用户可以在其中键入新动态。

不过在这之前，我们先支持下 flash message

controller/g.go

...
var (
	homeController home
	templates      map[string]*template.Template
	sessionName    string
	flashName      string
	store          *sessions.CookieStore
)

func init() {
	templates = PopulateTemplates()
	store = sessions.NewCookieStore([]byte("something-very-secret"))
	sessionName = "go-mega"
	flashName = "go-flash"
}
...

controller/utils.go

...

func setFlash(w http.ResponseWriter, r *http.Request, message string) {
	session, _ := store.Get(r, sessionName)
	session.AddFlash(message, flashName)
	session.Save(r, w)
}

func getFlash(w http.ResponseWriter, r *http.Request) string {
	session, _ := store.Get(r, sessionName)
	fm := session.Flashes(flashName)
	if fm == nil {
		return ""
	}
	session.Save(r, w)
	return fmt.Sprintf("%v", fm[0])
}

然后我们就可以使用 flash message 来提示 error message了

现在我们来完成发布Post功能

vm/index.go

package vm

import "github.com/bonfy/go-mega-code/model"

// IndexViewModel struct
type IndexViewModel struct {
	BaseViewModel
	Posts []model.Post
	Flash string
}

// IndexViewModelOp struct
type IndexViewModelOp struct{}

// GetVM func
func (IndexViewModelOp) GetVM(username string, flash string) IndexViewModel {
	u, _ := model.GetUserByUsername(username)
	posts, _ := u.FollowingPosts()
	v := IndexViewModel{BaseViewModel{Title: "Homepage"}, *posts, flash}
	v.SetCurrentUser(username)
	return v
}

// CreatePost func
func CreatePost(username, post string) error {
	u, _ := model.GetUserByUsername(username)
	return u.CreatePost(post)
}

Notice: 这里我们顺便将 IndexView 里的 Posts 改成 CurrentUser 的 FollowingPosts

templates/content/index.html

{{define "content"}}
    <h1>Hello, {{.CurrentUser}}!</h1>

    <form action="/" method="post">

        <p><textarea  name="body" rows="3" cols="80" value="" placeholder="say something..."></textarea></p>
        <p><input type="submit" name="submit" value="Post"></p>

        {{ if .Flash }}
            <span style="color: red;">[{{.Flash}}]</span>
        {{ end }}
    </form>

    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td>{{ .User.Username }} says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}
{{end}}

controller/home.go

...
func indexHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "index.html"
	vop := vm.IndexViewModelOp{}
	username, _ := getSessionUser(r)
	if r.Method == http.MethodGet {
		flash := getFlash(w, r)
		v := vop.GetVM(username, flash)
		templates[tpName].Execute(w, &v)
	}
	if r.Method == http.MethodPost {
		r.ParseForm()
		body := r.Form.Get("body")
		errMessage := checkLen("Post", body, 1, 180)
		if errMessage != "" {
			setFlash(w, r, errMessage)
		} else {
			err := vm.CreatePost(username, body)
			if err != nil {
				log.Println("add Post error:", err)
				w.Write([]byte("Error insert Post in database"))
				return
			}
		}
		http.Redirect(w, r, "/", http.StatusSeeOther)
	}
}
...

Notice: 这里由于我们上章初始化数据 bonfy follow了 rene，所以这里 bonfy 看到的 Index 页面中也有rene的Post,如果登陆rene的账户，是看不到bonfy的Post的，因为 rene 没有follow bonfy

我们现在在输入框中什么都不输入，直接点Post,就能看到 Flash 的 红色提示了

本小节 Diff

加入动态分页

应用看起来更完善了，但是在主页显示所有用户动态迟早会出问题。如果一个用户有成千上万条关注的用户动态时，会发生什么？你可以想象得到，管理这么大的用户动态列表将会变得相当缓慢和低效。

为了解决这个问题，我会将用户动态进行分页。这意味着一开始显示的只是所有用户动态的一部分，并提供链接来访问其余的用户动态。

我们先在 controller/g.go 中增加页数设置 pageLimit (其实更灵活点，也可以将它放入到配置文件中)

controller/g.go

...
var (
	homeController home
	templates      map[string]*template.Template
	sessionName    string
	flashName      string
	store          *sessions.CookieStore
	pageLimit      int
)

func init() {
	templates = PopulateTemplates()
	store = sessions.NewCookieStore([]byte("something-very-secret"))
	sessionName = "go-mega"
	flashName = "go-flash"
	pageLimit = 5
}
...

接下来，我需要决定如何将页码并入到应用URL中。 一个相当常见的方法是使用查询字符串参数来指定一个可选的页码，如果没有给出则默认为页面1。 以下是一些示例网址，显示了我将如何实现这一点：

第1页，隐含：http://localhost:8888/

第1页，显式：http://localhost:8888/?page=1

第3页：http://localhost:8888/?page=3

要访问查询字符串中给出的参数,我们在 utils 中创建一个函数,方便以后调用

...

func getPage(r *http.Request) int {
	url := r.URL         // net/url.URL
	query := url.Query() // Values (map[string][]string)

	q := query.Get("page")
	if q == "" {
		return 1
	}

	page, err := strconv.Atoi(q)
	if err != nil {
		return 1
	}
	return page
}

在 vm 中建立分页的 BasePageViewModel

PrevPage: 上一页的页码

NextPage: 下一页的页码

Total: 总页数

CurrentPage: 当前页码

Limit: 每页显示项目数

vm/g.go

...

// BasePageViewModel struct
type BasePageViewModel struct {
	PrevPage    int
	NextPage    int
	Total       int
	CurrentPage int
	Limit       int
}

// SetPrevAndNextPage func
func (v *BasePageViewModel) SetPrevAndNextPage() {
	if v.CurrentPage > 1 {
		v.PrevPage = v.CurrentPage - 1
	}

	if (v.Total-1)/v.Limit >= v.CurrentPage {
		v.NextPage = v.CurrentPage + 1
	}
}

// SetBasePageViewModel func
func (v *BasePageViewModel) SetBasePageViewModel(total, page, limit int) {
	v.Total = total
	v.CurrentPage = page
	v.Limit = limit
	v.SetPrevAndNextPage()
}

首页中的分页

model 中增加 FollowingPosts 的分页处理

model/user.go

...
// FollowingPostsByPageAndLimit func
func (u *User) FollowingPostsByPageAndLimit(page, limit int) (*[]Post, int, error) {
	var total int
	var posts []Post
	offset := (page - 1) * limit
	ids := u.FollowingIDs()
	if err := db.Preload("User").Order("timestamp desc").Where("user_id in (?)", ids).Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
		return nil, total, err
	}
	db.Model(&Post{}).Where("user_id in (?)", ids).Count(&total)
	return &posts, total, nil
}
...

index 的 vm 中加入 BasePageViewModel

vm/index.go

...
type IndexViewModel struct {
	BaseViewModel
	Posts []model.Post
	Flash string

	BasePageViewModel
}

// IndexViewModelOp struct
type IndexViewModelOp struct{}

// GetVM func
func (IndexViewModelOp) GetVM(username, flash string, page, limit int) IndexViewModel {
	u, _ := model.GetUserByUsername(username)
	posts, total, _ := u.FollowingPostsByPageAndLimit(page, limit)
	v := IndexViewModel{}
	v.SetTitle("Homepage")
	v.Posts = *posts
	v.Flash = flash
	v.SetBasePageViewModel(total, page, limit)
	v.SetCurrentUser(username)
	return v
}
...

显示页中加入页码

templates/content/index.html

...
    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td><a href="/user/{{.User.Username}}">{{ .User.Username }}</a> says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}

    {{ if gt .PrevPage 0 }}
        <a href="/?page={{.PrevPage}}">Newer posts</a>
    {{ end }}
    {{ if gt .NextPage 0 }}
        <a href="/?page={{.NextPage}}">Older posts</a>
    {{ end }}
...

Notice: 这里我们顺便在 Posts显示的时候将 Username 上加了 “/user/username” 的链接,方便我们快速访问用户profile

controller修改成新的 GetVM

...
func indexHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "index.html"
	vop := vm.IndexViewModelOp{}
	page := getPage(r)
	username, _ := getSessionUser(r)
	if r.Method == http.MethodGet {
		flash := getFlash(w, r)
		v := vop.GetVM(username, flash, page, pageLimit)
		templates[tpName].Execute(w, &v)
	}
	if r.Method == http.MethodPost {
		r.ParseForm()
		body := r.Form.Get("body")
		errMessage := checkLen("Post", body, 1, 180)
		if errMessage != "" {
			setFlash(w, r, errMessage)
		} else {
			err := vm.CreatePost(username, body)
			if err != nil {
				log.Println("add Post error:", err)
				w.Write([]byte("Error insert Post in database"))
				return
			}
		}
		http.Redirect(w, r, "/", http.StatusSeeOther)
	}
}
...

个人主页中的分页

与首页分页类似，建立 profile 中的分页

model/post.go

...
// GetPostsByUserIDPageAndLimit func
func GetPostsByUserIDPageAndLimit(id, page, limit int) (*[]Post, int, error) {
	var total int
	var posts []Post
	offset := (page - 1) * limit
	if err := db.Preload("User").Order("timestamp desc").Where("user_id=?", id).Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
		return nil, total, err
	}
	db.Model(&Post{}).Where("user_id=?", id).Count(&total)
	return &posts, total, nil
}

vm/profile.go

...
// ProfileViewModel struct
type ProfileViewModel struct {
	BaseViewModel
	Posts          []model.Post
	Editable       bool
	IsFollow       bool
	FollowersCount int
	FollowingCount int
	ProfileUser    model.User
	BasePageViewModel
}

// ProfileViewModelOp struct
type ProfileViewModelOp struct{}

// GetVM func
func (ProfileViewModelOp) GetVM(sUser, pUser string, page, limit int) (ProfileViewModel, error) {
	v := ProfileViewModel{}
	v.SetTitle("Profile")
	u, err := model.GetUserByUsername(pUser)
	if err != nil {
		return v, err
	}
	posts, total, _ := model.GetPostsByUserIDPageAndLimit(u.ID, page, limit)
	v.ProfileUser = *u
	v.Editable = (sUser == pUser)
	v.SetBasePageViewModel(total, page, limit)
	if !v.Editable {
		v.IsFollow = u.IsFollowedByUser(sUser)
	}
	v.FollowersCount = u.FollowersCount()
	v.FollowingCount = u.FollowingCount()

	v.Posts = *posts
	v.SetCurrentUser(sUser)
	return v, nil
}
...

templates/content/profile.html

...

    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td><a href="/user/{{.User.Username}}">{{ .User.Username }}</a> says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}

    {{ if gt .PrevPage 0 }}
        <a href="/user/{{.ProfileUser.Username}}?page={{.PrevPage}}">Newer posts</a>
    {{ end }}
    {{ if gt .NextPage 0 }}
        <a href="/user/{{.ProfileUser.Username}}?page={{.NextPage}}">Older posts</a>
    {{ end }}
...

controller/home.go

...
func profileHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "profile.html"
	vars := mux.Vars(r)
	pUser := vars["username"]
	sUser, _ := getSessionUser(r)
	page := getPage(r)
	vop := vm.ProfileViewModelOp{}
	v, err := vop.GetVM(sUser, pUser, page, pageLimit)
	if err != nil {
		msg := fmt.Sprintf("user ( %s ) does not exist", pUser)
		w.Write([]byte(msg))
		return
	}
	templates[tpName].Execute(w, &v)
}
...

本小节 Diff

更容易地发现和关注用户

相信你已经留意到了，应用没有一个很好的途径来让用户可以找到其他用户进行关注。实际上，现在根本没有办法在页面上查看到底有哪些用户存在。我将会使用少量简单的变更来解决这个问题。

我将会创建一个新的Explore页面。该页面看起来像是主页，但是却不是只显示已关注用户的动态，而是展示所有用户的全部动态。

我们现在导航中加入Explore

templates/_base.html

...
        <a href="/">Home</a>
        <a href="/explore">Explore</a>
...

然后增加 Explore 页面， 不多说了，老套路

model/post.go

...
// GetPostsByPageAndLimit func
func GetPostsByPageAndLimit(page, limit int) (*[]Post, int, error) {
	var total int
	var posts []Post

	offset := (page - 1) * limit
	if err := db.Preload("User").Offset(offset).Limit(limit).Order("timestamp desc").Find(&posts).Error; err != nil {
		return nil, total, err
	}

	db.Model(&Post{}).Count(&total)

	return &posts, total, nil
}

vm/explore.go

package vm

import "github.com/bonfy/go-mega-code/model"

// ExploreViewModel struct
type ExploreViewModel struct {
	BaseViewModel
	Posts []model.Post
	BasePageViewModel
}

// ExploreViewModelOp struct
type ExploreViewModelOp struct{}

// GetVM func
func (ExploreViewModelOp) GetVM(username string, page, limit int) ExploreViewModel {
	// posts, _ := model.GetAllPosts()
	posts, total, _ := model.GetPostsByPageAndLimit(page, limit)
	v := ExploreViewModel{}
	v.SetTitle("Explore")
	v.Posts = *posts
	v.SetBasePageViewModel(total, page, limit)
	v.SetCurrentUser(username)
	return v
}

templates/content/explore.html

{{define "content"}}
    <h1>Hello, {{.CurrentUser}}!</h1>

    {{range .Posts}}
        <table>
            <tr valign="top">
                <td><img src="{{.User.Avatar}}&s=36"></td>
                <td><a href="/user/{{.User.Username}}">{{ .User.Username }}</a> says:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}

    {{ if gt .PrevPage 0 }}
        <a href="/explore?page={{.PrevPage}}">Newer posts</a>
    {{ end }}
    {{ if gt .NextPage 0 }}
        <a href="/explore?page={{.NextPage}}">Older posts</a>
    {{ end }}
{{end}}

controller/home.go

...
func (h home) registerRoutes() {
	r := mux.NewRouter()
	r.HandleFunc("/logout", middleAuth(logoutHandler))
	r.HandleFunc("/login", loginHandler)
	r.HandleFunc("/register", registerHandler)
	r.HandleFunc("/user/{username}", middleAuth(profileHandler))
	r.HandleFunc("/follow/{username}", middleAuth(followHandler))
	r.HandleFunc("/unfollow/{username}", middleAuth(unFollowHandler))
	r.HandleFunc("/profile_edit", middleAuth(profileEditHandler))
	r.HandleFunc("/explore", middleAuth(exploreHandler))
	r.HandleFunc("/", middleAuth(indexHandler))

	http.Handle("/", r)
}

...

func exploreHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "explore.html"
	vop := vm.ExploreViewModelOp{}
	username, _ := getSessionUser(r)
	page := getPage(r)
	v := vop.GetVM(username, page, pageLimit)
	templates[tpName].Execute(w, &v)
}

通过这些细小的变更，应用的用户体验得到了大大的提升。现在，用户可以访问发现页来查看陌生用户的动态，并通过这些用户动态来关注用户，而需要的操作仅仅是点击用户名跳转到其个人主页并点击关注链接。令人叹为观止！对吧？

此时，我建议你在应用上再次尝试一下这个功能，以便体验最后的用户接口的完善。

本小节 Diff

Links

目录

上一节: 08-Follower

下一节: 10-Email-Support

10-Email Support

这是Go-Mega系列的第十部分，本章我将告诉你，应用如何向你的用户发送电子邮件，以及如何在电子邮件支持之上构建密码重置功能。

现在，应用在数据库方面做得相当不错，所以在本章中，我想抛开这个主题，开始添加发送电子邮件的功能，这是大多数Web应用必需的另一个重要部分。

为什么应用需要发送电子邮件给用户？ 原因很多，但其中一个常见的原因是解决与认证相关的问题。 在本章中，我将为忘记密码的用户添加密码重置功能。 当用户请求重置密码时，应用将发送包含特制链接的电子邮件。 用户然后需要点击该链接才能访问设置新密码的表单。

本章的GitHub链接为： Source, Diff, Zip

第三方库支持

本章我们需要两个第三方插件 gomail 以及 jwt-go

# gomail
$ go get gopkg.in/gomail.v2

# jwt-go
$ go get github.com/dgrijalva/jwt-go

加入mail支持

在 config 中增加 mail 设置

config.yml

mysql:
  charset: utf8
  db: dbname
  host: localhost
  password: password
  user: root
mail:
  smtp: smtp-server
  smtp-port: 587
  user: user
  password: pwd

这里的 smtp server， 请查看你的邮件提供商的文档，比如 zoho mail 的 smtp 是 smtp.zoho.com

config/g.go

...

// GetSMTPConfig func
func GetSMTPConfig() (server string, port int, user, pwd string) {
	server = viper.GetString("mail.smtp")
	port = viper.GetInt("mail.smtp-port")
	user = viper.GetString("mail.user")
	pwd = viper.GetString("mail.password")
	return
}

在 controller/utils.go 封装 sendMail 函数，方便调用

controller/utils.go

...

// Email

// sendEmail func
func sendEmail(target, subject, content string) {
	server, port, usr, pwd := config.GetSMTPConfig()
	d := gomail.NewDialer(server, port, usr, pwd)
	d.TLSConfig = &tls.Config{InsecureSkipVerify: true}

	m := gomail.NewMessage()
	m.SetHeader("From", usr)
	m.SetHeader("To", target)
	m.SetAddressHeader("Cc", usr, "admin")
	m.SetHeader("Subject", subject)
	m.SetBody("text/html", content)

	if err := d.DialAndSend(m); err != nil {
		log.Println("Email Error:", err)
		return
	}
}

本小节 Diff

请求重置密码

我上面提到过，用户有权利重置密码。因此我将在登录页面提供一个链接：

templates/content/login.html

...
    <p>New User? <a href="/register">Click to Register!</a></p>
    <p>Forget Password? <a href="/reset_password_request">Click to Reset Password!</a></p>
...

当用户点击链接时，会出现一个新的Web表单，要求用户输入注册的电子邮件地址，以启动密码重置过程。

vm/reset_password_request.go

package vm

import (
	"log"

	"github.com/bonfy/go-mega-code/model"
)

// ResetPasswordRequestViewModel struct
type ResetPasswordRequestViewModel struct {
	LoginViewModel
}

// ResetPasswordRequestViewModelOp struct
type ResetPasswordRequestViewModelOp struct{}

// GetVM func
func (ResetPasswordRequestViewModelOp) GetVM() ResetPasswordRequestViewModel {
	v := ResetPasswordRequestViewModel{}
	v.SetTitle("Forget Password")
	return v
}

// CheckEmailExist func
func CheckEmailExist(email string) bool {
	_, err := model.GetUserByEmail(email)
	if err != nil {
		log.Println("Can not find email:", email)
		return false
	}
	return true
}

templates/content/reset_password_request.html

{{define "content"}}
    <h1>Input your email address:</h1>
    <form action="/reset_password_request" method="post" name="reset_password_request">
        <p><input type="text" class="form-control" name="email" value="" placeholder="Email"></p>
        <p><input type="submit" class="btn btn-outline-primary" name="submit" value="Submit" ></p>
    </form>

    {{if .Errs}}
    <ul>
        {{range .Errs}}
            <li>{{.}}</li>
         {{end}}
    </ul>
    {{end}}
{{end}}

controller/home.go

...

r.HandleFunc("/reset_password_request", resetPasswordRequestHandler)
...

func resetPasswordRequestHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "reset_password_request.html"
	vop := vm.ResetPasswordRequestViewModelOp{}
	v := vop.GetVM()

	if r.Method == http.MethodGet {
		templates[tpName].Execute(w, &v)
	}
	if r.Method == http.MethodPost {
		r.ParseForm()
		email := r.Form.Get("email")

		errs := checkResetPasswordRequest(email)
		v.AddError(errs...)

		if len(v.Errs) > 0 {
			templates[tpName].Execute(w, &v)

		} else {
			log.Println("Send mail to", email)
			vopEmail := vm.EmailViewModelOp{}
			vEmail := vopEmail.GetVM(email)
			var contentByte bytes.Buffer
			tpl, _ := template.ParseFiles("templates/email.html")

			if err := tpl.Execute(&contentByte, &vEmail); err != nil {
				log.Println("Get Parse Template:", err)
				w.Write([]byte("Error send email"))
				return
			}
			content := contentByte.String()
			go sendEmail(email, "Reset Password", content)
			http.Redirect(w, r, "/login", http.StatusSeeOther)
		}
	}
}

controller 里涉及到了 email 的template

vm/email.go

package vm

import (
	"github.com/bonfy/go-mega-code/config"
	"github.com/bonfy/go-mega-code/model"
)

// EmailViewModel struct
type EmailViewModel struct {
	Username string
	Token    string
	Server   string
}

// EmailViewModelOp struct
type EmailViewModelOp struct{}

// GetVM func
func (EmailViewModelOp) GetVM(email string) EmailViewModel {
	v := EmailViewModel{}
	u, _ := model.GetUserByEmail(email)
	v.Username = u.Username
	v.Token, _ = u.GenerateToken()
	v.Server = config.GetServerURL()
	return v
}

templates/email.html

<p>Dear {{.Username}},</p>
<p>
    To reset your password
    <a href="{{.Server}}/reset_password/{{.Token}}">
        click here
    </a>.
</p>
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
<p>{{.Server}}/reset_password/{{.Token}}</p>
<p>If you have not requested a password reset simply ignore this message.</p>
<p>This Email will expire in 2 hours.</p>
<p>Sincerely,</p>
<p>BONFY</p>

上面简单的一封邮件，其实蕴含着两个 非常重要的 知识点：

goroutine

原来 Flask-Mega 里面非常复杂的多线程操作，这里只用了一个 go function() 完成了

goroutine 可以说是 Go 这个语言的特色之一了，当然资料也比较多，大家可以深入了解下

这里我简单说下，就是 function 前面加个 go 关键字，就实现了 协程，用于高并发，而且性能非常好，是不是很cool！

jwt

具体可以通过 jwt.io了解下

或者中文的可以通过这片文章具体了解下 直通车

这里的邮件可以说是 JWT 的一个非常典型的应用场景，jwt 加密后的 URL 就是图中的一长串，其中其实隐含着 2 hour 过期时间

我们通过在 user.go 里加入两个function 就能实现, 密钥 secret 这里直接写在代码里，其实更优还是通过配置文件配置，又偷懒了

model/user.go

...

// GenerateToken func
func (u *User) GenerateToken() (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"username": u.Username,
		"exp":      time.Now().Add(time.Hour * 2).Unix(), // 可以添加过期时间
	})
	return token.SignedString([]byte("secret"))
}

// CheckToken func
func CheckToken(tokenString string) (string, error) {
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		// Don't forget to validate the alg is what you expect:
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
		}

		// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
		return []byte("secret"), nil
	})

	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		return claims["username"].(string), nil
	} else {
		return "", err
	}
}

...

本小节 Diff

重置密码

目前我们点击邮件中的邮件是不能操作的，因为我们其实并没有 /reset_password 这个handler，那这里我们来完成它

其实密码重置就是一个简单的表单， 两个密码框，检验输入一致以及符合密码规范就可以了，操作起来不算太复杂。

vm/reset_password.go

package vm

import (
	"github.com/bonfy/go-mega-code/model"
)

// ResetPasswordViewModel struct
type ResetPasswordViewModel struct {
	LoginViewModel
	Token string
}

// ResetPasswordViewModelOp struct
type ResetPasswordViewModelOp struct{}

// GetVM func
func (ResetPasswordViewModelOp) GetVM(token string) ResetPasswordViewModel {
	v := ResetPasswordViewModel{}
	v.SetTitle("Reset Password")
	v.Token = token
	return v
}

// CheckToken func
func CheckToken(tokenString string) (string, error) {
	return model.CheckToken(tokenString)
}

// ResetUserPassword func
func ResetUserPassword(username, password string) error {
	return model.UpdatePassword(username, password)
}

templates/content/reset_password.html

{{define "content"}}
    <h1>Register</h1>
    <form action="/reset_password/{{.Token}}" method="post" name="reset_password">
        <p><input type="password" name="pwd1" value="" placeholder="Password"></p>
        <p><input type="password" name="pwd2" value="" placeholder="Confirm Password"></p>
        <p><input type="submit" name="submit" value="Reset"></p>
    </form>

    {{if .Errs}}
    <ul>
        {{range .Errs}}
            <li>{{.}}</li>
         {{end}}
    </ul>
    {{end}}
{{end}}

controller/home.go

...

r.HandleFunc("/reset_password/{token}", resetPasswordHandler)

...

func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	token := vars["token"]
	username, err := vm.CheckToken(token)
	if err != nil {
		w.Write([]byte("The token is no longer valid, please go to the login page."))
	}

	tpName := "reset_password.html"
	vop := vm.ResetPasswordViewModelOp{}
	v := vop.GetVM(token)

	if r.Method == http.MethodGet {
		templates[tpName].Execute(w, &v)
	}

	if r.Method == http.MethodPost {
		log.Println("Reset password for ", username)
		r.ParseForm()
		pwd1 := r.Form.Get("pwd1")
		pwd2 := r.Form.Get("pwd2")

		errs := checkResetPassword(pwd1, pwd2)
		v.AddError(errs...)

		if len(v.Errs) > 0 {
			templates[tpName].Execute(w, &v)
		} else {
			if err := vm.ResetUserPassword(username, pwd1); err != nil {
				log.Println("reset User password error:", err)
				w.Write([]byte("Error update user password in database"))
				return
			}
			http.Redirect(w, r, "/login", http.StatusSeeOther)
		}
	}
}

就这样我们完成了重置密码的功能，虽然设计到的页面比较多，不过只要我们思路清晰，一步一个脚印，还是能非常容易就能实现了。

现在输入新的密码，保存后，就可以用新密码登陆了。

本小节 Diff

Notice: 本章还涉及到一些后端验证，在这里没有一一列举，大家还是请看下源码diff，可以更完整的了解代码

Links

目录

上一节: 09-Pagination

下一节: 11-Facelift

11-Facelift

在本章中，我暂停一下后端的工作，并花点时间向你展示如何使应用看起来更加优雅和专业。

本章主要是用到了 前端的 Bootstrap

本章的GitHub链接为： Source, Diff, Zip

CSS 框架

虽然我们可以争辩说写代码不容易，但是与那些必须让网页在所有Web浏览器上具有良好一致外观的网页设计师相比，我们的痛苦不值一提。 虽然近年来这种情况得到一定程度的缓解，但是在一些浏览器中仍然存在着晦涩的错误或奇怪的设定，这使得设计网页的任务变得非常困难。 如果还需要兼容屏幕限制设备（诸如平板电脑和智能手机）的浏览器，则更加困难。

如果你和我一样，只是一个想创建出规范网页的开发人员，没有时间或兴趣去学习底层机制并通过编写原生HTML和CSS来实现它，那么唯一可行的解决方案是使用CSS框架来简化任务。 通过这种方式，你会失去一些创造性的自由，但另一方面，无需通过太多的功夫就可以让网页在所有浏览器中看起来都不错。 CSS框架为普通类型的用户界面元素提供了高级CSS类的集合，其中包含预定义样式。 大多数这样的框架还提供JavaScript插件，以实现不能纯粹使用HTML和CSS来完成的功能。

Bootstrap简介

最受欢迎的CSS框架之一是由Twitter推出的Bootstrap。 如果你想看看这个框架可以设计的页面类型，文档有一些示例。

这些是使用Bootstrap来设置网页风格的一些好处：

在所有主流网页浏览器中都有相似的外观

自动处理PC桌面，平板电脑和手机屏幕尺寸

可定制的布局

精心设计的导航栏，表单，按钮，警示，弹出窗口等

Bootstrap美化

由于我们所有的页面都是继承自 templates/_base.html，所以我们需要在 _base.html 中引入 bootstrap

templates/_base.html

<html>
    <head>
        {{if .Title}}
        <title>{{.Title}} - blog</title>
        {{else}}
        <title>Welcome to blog!</title>
        {{end}}

        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

        <style>
            .container {
                max-width: 960px;
            }
        </style>

    </head>
    <body>
        <div class="d-flex flex-column flex-md-row align-items-center p-3 px-md-4 mb-3 bg-white border-bottom shadow-sm">
            <h5 class="my-0 mr-md-auto font-weight-normal">Blog</h5>
            <nav class="my-2 my-md-0 mr-md-3">
                <a class="p-2 text-dark" href="/">Home</a>
                <a class="p-2 text-dark" href="/explore">Explore</a>
                {{if .CurrentUser}}
                    <a class="p-2 text-dark" href="/user/{{.CurrentUser}}">Profile</a>
                {{end}}
            </nav>

            {{if .CurrentUser}}
                <a class="btn btn-outline-primary" href="/logout">Logout</a>
            {{else}}
                <a class="btn btn-outline-primary" href="/login">Login</a>
            {{end}}
        </div>

        <div class="container">
            {{template "content" .}}
        </div>

        <script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"></script>
    </body>
</html>

接着我们主要优化的是表单和按钮

所有表单上加上 class="form-control"

所有按钮上加上 class="btn btn-outline-primary"

所有的分页按钮优化

    <nav aria-label="...">
        <ul class="pagination justify-content-center">
        {{ if gt .PrevPage 0 }}
            <li class="page-item">
                <a href="/user/{{.ProfileUser.Username}}?page={{.PrevPage}}">
                    <span class="page-link" aria-hidden="true">&larr; Newer Posts</span>
                </a>
            </li>
        {{ else }}
            <li class="page-item disabled">
                <a href="#">
                    <span class="page-link" aria-hidden="true">&larr; Newer Posts</span>
                </a>
            </li>
        {{ end }}
        {{ if gt .NextPage 0 }}
            <li class="page-item">
                <a href="/user/{{.ProfileUser.Username}}?page={{.NextPage}}">
                    <span class="page-link" aria-hidden="true">Older Posts &rarr;</span>
                </a>
            </li>
        {{ else }}
            <li class="page-item disabled">
                <a href="#">
                    <span class="page-link" aria-hidden="true">Older Posts &rarr;</span>
                </a>
            </li>
        {{ end }}

        </ul>
    </nav>

本小节 Diff

自定义 404 界面

我们现在的404界面还是 gorilla/mux 的默认界面，现在我们美化下

vm/notfound.go

package vm

// NotFoundMessage struct
type NotFoundMessage struct {
	Flash string
}

templates/404.html

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Page Not Found</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
            * {
                line-height: 1.2;
                margin: 0;
            }
            html {
                color: #888;
                display: table;
                font-family: sans-serif;
                height: 100%;
                text-align: center;
                width: 100%;
            }
            body {
                display: table-cell;
                vertical-align: middle;
                margin: 2em auto;
            }
            h1 {
                color: #555;
                font-size: 2em;
                font-weight: 400;
            }
            p {
                margin: 0 auto;
                width: 280px;
            }
            @media only screen and (max-width: 280px) {
                body, p {
                    width: 95%;
                }
                h1 {
                    font-size: 1.5em;
                    margin: 0 0 0.3em;
                }
            }
        </style>
    </head>
    <body>
        {{if .Flash}}
        <h1>{{.Flash}}</h1>
        {{else}}
        <h1>Page Not Found</h1>
        {{end}}
        <p>Sorry, but the page you were trying to view does not exist.</p>
    </body>
</html>

controller/home.go

...
r.NotFoundHandler = http.HandlerFunc(notfoundHandler)
r.HandleFunc("/404", notfoundHandler)
...

func notfoundHandler(w http.ResponseWriter, r *http.Request) {
	flash := getFlash(w, r)
	message := vm.NotFoundMessage{Flash: flash}
	tpl, _ := template.ParseFiles("templates/404.html")
	tpl.Execute(w, &message)
}
...

运行程序，在浏览器中输入一个没有指定的 url，例如: http://127.0.0.1:8888/nosuchpage

本小节 Diff

Links

目录

上一节: 10-Email-Support

下一节: 12-Dates-And-Times

12-Dates and Times

在本章，我们将讨论下 Go 处理日期和时间的方法，以及优化 User 及 Post 对象中的时间对象

本章的GitHub链接为： Source, Diff, Zip

优化 User 的 LastSeen

Flask-Mega 主要是用了它灵活的模板功能，以及 Moment.js 来在前端实现了时间日期的优化，与之不同，我们主要采用的是后端来实现同样的功能

model/user.go

...
// FormattedLastSeen func
func (u *User) FormattedLastSeen() string {
	return u.LastSeen.Format("2006-01-02 15:04:05")
}
...

templates/content/profile.html

...
    <p>Last seen on: {{ .ProfileUser.FormattedLastSeen }}</p>
...

Notice: 虽然我也比较喜欢其他语言的 %Y-%m-%d %H:%M:%S" layout 形式，但是无奈 Go 就是 "2006-01-02 15:04:05" 这样的layout，没有道理可讲，我有时候甚至怕它搞错 01 和 02 哪个是月 哪个表示 日

本小节 Diff

优化 Post 的 Timestamp

我们目标是显示成 user said 35 minutes ago: post message 这样的形式，这就需要我们对时间日期进行转换

简单点，我们就在 utils.go 中实现一个 FromTime 函数

model/utils.go

...

const (
	minute  = 1
	hour    = minute * 60
	day     = hour * 24
	month   = day * 30
	year    = day * 365
	quarter = year / 4
)

// FromDuration returns a friendly string representing an approximation of the
// given duration
func FromDuration(d time.Duration) string {
	seconds := round(d.Seconds())

	if seconds < 30 {
		return "less than a minute"
	}

	if seconds < 90 {
		return "1 minute"
	}

	minutes := div(seconds, 60)

	if minutes < 45 {
		return fmt.Sprintf("%0d minutes", minutes)
	}

	hours := div(minutes, 60)

	if minutes < day {
		return fmt.Sprintf("about %s", pluralize(hours, "hour"))
	}

	if minutes < (42 * hour) {
		return "1 day"
	}

	days := div(hours, 24)

	if minutes < (30 * day) {
		return pluralize(days, "day")
	}

	months := div(days, 30)

	if minutes < (45 * day) {
		return "about 1 month"
	}

	if minutes < (60 * day) {
		return "about 2 months"
	}

	if minutes < year {
		return pluralize(months, "month")
	}

	rem := minutes % year
	years := minutes / year

	if rem < (3 * month) {
		return fmt.Sprintf("about %s", pluralize(years, "year"))
	}
	if rem < (9 * month) {
		return fmt.Sprintf("over %s", pluralize(years, "year"))
	}

	years++
	return fmt.Sprintf("almost %s", pluralize(years, "year"))
}

// FromTime returns a friendly string representing the approximate difference
// from the given time and time.Now()
func FromTime(t time.Time) string {
	now := time.Now()

	var d time.Duration
	var suffix string

	if t.Before(now) {
		d = now.Sub(t)
		suffix = "ago"
	} else {
		d = t.Sub(now)
		suffix = "from now"
	}

	return fmt.Sprintf("%s %s", FromDuration(d), suffix)
}

func pluralize(i int, s string) string {
	var buf bytes.Buffer
	buf.WriteString(fmt.Sprintf("%d %s", i, s))
	if i != 1 {
		buf.WriteString("s")
	}
	return buf.String()
}

func round(f float64) int {
	return int(math.Floor(f + .50))
}

func div(numerator int, denominator int) int {
	rem := numerator % denominator
	result := numerator / denominator

	if rem >= (denominator / 2) {
		result++
	}

	return result
}

现在我们可以使用 FromTime 函数实现我们的功能了

model/post.go

...
// FormattedTimeAgo func
func (p *Post) FormattedTimeAgo() string {
	return FromTime(*p.Timestamp)
}
...

templates/content/index.html & profile.html & explore.html

...
    {{range .Posts}}
        <table class="table table-hover">
            <tr valign="top">
                <td width="36px"><img src="{{.User.Avatar}}&s=36"></td>
                <td><a href="/user/{{.User.Username}}">{{ .User.Username }}</a> said {{.FormattedTimeAgo}}:<br>{{ .Body }}</td>
            </tr>
        </table>
    {{end}}
...

本小节 Diff

写在最后: 突然发现模板可以使用 类的 Function, 所以前面 User 的 Avatar 字段貌似就没有那么必要了，直接用个function来实现，而且好处是不用去关心更改邮箱后的 SetAvatar,不过我任性，就不改了，我也是才发现 Go 模板还有这个妙用的

Links

目录

上一节: 11-Facelift

下一节: 13-Javascript-Magic

13-Javascript Magic

在本章，我们将添加一个功能，当你将鼠标悬停在用户的昵称上时，会弹出一个漂亮的窗口。

社交网站的常见用户交互模式是，当你将鼠标悬停在用户的名称上时，可以在弹出窗口中显示用户的主要信息。 如果你从未注意到这一点，请访问Twitter，Facebook，LinkedIn或任何其他主要社交网站，当你看到用户名时，只需将鼠标指针放在上面几秒钟即可看到弹出窗口。

本章的GitHub链接为： Source, Diff, Zip

服务器端支持

在深入研究客户端之前，让我们先了解一下支持这些用户弹窗所需的服务器端的工作。 用户弹窗的内容将由新路由返回，它是现有个人主页路由的简化版本。

viewmodel 我们偷下懒，由于Popup的 vm 和 Profile 的相似，我们直接在 vm/profile.go中加入 GetPopupVM 来获得 Popup 的 vm

vm/profile.go

...
    // GetPopupVM func
func (ProfileViewModelOp) GetPopupVM(sUser, pUser string) (ProfileViewModel, error) {
	v := ProfileViewModel{}
	v.SetTitle("Profile")
	u, err := model.GetUserByUsername(pUser)
	if err != nil {
		return v, err
	}
	v.ProfileUser = *u
	v.Editable = (sUser == pUser)
	if !v.Editable {
		v.IsFollow = u.IsFollowedByUser(sUser)
	}
	v.FollowersCount = u.FollowersCount()
	v.FollowingCount = u.FollowingCount()
	v.SetCurrentUser(sUser)
	return v, nil
}

...

controller/home.go

...
r.HandleFunc("/user/{username}/popup", popupHandler)

...
func popupHandler(w http.ResponseWriter, r *http.Request) {
	tpName := "popup.html"
	vars := mux.Vars(r)
	pUser := vars["username"]
	sUser, _ := getSessionUser(r)
	vop := vm.ProfileViewModelOp{}
	v, err := vop.GetPopupVM(sUser, pUser)
	if err != nil {
		msg := fmt.Sprintf("user ( %s ) does not exist", pUser)
		w.Write([]byte(msg))
		return
	}
	templates[tpName].Execute(w, &v)
}

...

templates/content/popup.html

<table>
    <tr valign="top">
        <td width="64" style="border: 0px;"><img src="{{.ProfileUser.Avatar}}&s=64"></td>
        <td style="border: 0px;">
            <small>
                <p><a href="/user/{{.ProfileUser.Username}}">{{.ProfileUser.Username}}</a></p>
                {{if .ProfileUser.AboutMe}}
                    <p>{{ .ProfileUser.AboutMe }}</p>
                {{end}}

                {{if .ProfileUser.LastSeen}}
                    <p>Last seen on: {{ .ProfileUser.FormattedLastSeen }}</p>
                {{end}}

                <p>{{ .FollowersCount }} followers, {{ .FollowingCount }} following.</p>

                {{if .Editable}}
                    <p><a href="/profile_edit">Edit your profile</a></p>
                {{else}}
                    {{if .IsFollow}}
                        <p><a  class="btn btn-outline-primary" href="/unfollow/{{.ProfileUser.Username}}">Unfollow</a></p>
                    {{else}}
                        <p><a  class="btn btn-outline-primary" href="/follow/{{.ProfileUser.Username}}">Follow</a></p>
                    {{end}}
                {{end}}
            </small>
        </td>
    </tr>
</table>

当用户将鼠标指针悬停在用户名上时，随后小节中编写的JavaScript代码将会调用此路由。客户端将服务器端返回的响应中的html内容显示在弹出窗口中。 当用户移开鼠标时，弹出窗口将被删除。 听起来很简单，对吧？

如果你想了解弹窗像什么样，现在可以运行应用，跳转到任何用户的个人主页，然后在地址栏的URL中追加*/popup*以查看全屏版本的弹出窗口内容。

本小节 Diff

客户端Ajax

我们在 _base.html 中加入 popup 的 Ajax,这样所有继承它的页面也同样继承了 popup的功能

templates/_base.html

...
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
    <script>
        $(function () {
        var timer = null;
        var xhr = null;
        $('.user_popup').hover(
            function(event) {
                // mouse in event handler
                var elem = $(event.currentTarget);
                timer = setTimeout(function() {
                    timer = null;
                    xhr = $.ajax(
                        '/user/' + elem.first().text().trim() + '/popup').done(
                            function(data) {
                                xhr = null;
                                elem.popover({
                                    trigger: 'manual',
                                    html: true,
                                    animation: false,
                                    container: elem,
                                    content: data
                                }).popover('show');
                            }
                        );
                }, 1000);
            },
            function(event) {
                // mouse out event handler
                var elem = $(event.currentTarget);
                if (timer) {
                    clearTimeout(timer);
                    timer = null;
                }
                else if (xhr) {
                    xhr.abort();
                    xhr = null;
                }
                else {
                    elem.popover('hide');
                }
            }
        );
    });
    </script>

...

然后我们在需要有 Popup 功能的地方，就是所有的用户Post的头像地方加入 class='user_popup'

templates/content/index.html & explore.html & profile.html

...
    <td><span class="user_popup"><a href="/user/{{.User.Username}}">{{ .User.Username }}</a></span> said {{.FormattedTimeAgo}}:<br>{{ .Body }}</td>
...

本小节 Diff

Links

目录

上一节: 12-Dates-And-Times

下一节: 14-Deployment-On-Heroku

14-Deployment On Heroku

在本章，我会将应用部署到Heroku云平台

许多云托管提供商提供了一个应用程序可以运行的托管平台。 你只需提供部署到这些平台上的实际应用程序，因为硬件，操作系统，脚本语言解释器，数据库等都由该服务管理。 这种服务称为平台即服务（PaaS）。

Heroku,这是一种流行的云托管服务,对Python、Go、Nodejs等应用程序支持都很好,关键还免费,而且默认支持HTTPS

本章的GitHub链接为： Source, Diff, Zip

Heroku

Heroku 是首批PaaS平台之一。 它以Ruby的应用程序的托管服务开始，随后逐渐发展到支持诸多其他语言，如Java，Node.js，Python 还有Go。

在 Heroku 中部署Web应用程序主要是通过git版本控制工具完成的，因此你必须将应用程序放在git代码库中。 在通过git将应用程序上传到 Heroku 的服务器之后，你的工作基本就完成了，只需等待几秒钟，应用程序就会上线。 整个操作流程就是这么简单。

Heroku提供不同的服务级别，允许你自主选择为应用程序提供多少计算能力和运行时间，随着用户群的增长，你需要购买更多的“dynos”计算单元。

创建Heroku账户

在部署应用到 Heroku 之前，你需要拥有一个帐户。 所以请访问 heroku.com 并创建一个免费账户。 一旦注册成功并登录到 Heroku，你将可以访问一个dashboard，其中列出了你的所有应用程序。

安装Heroku命令行工具

我们可以在 Heroku 的 dashboard 上完成所有操作，不过还有个更简便的方法，就是安装Heroku cli 工具

$ brew install heroku

然后我们可以通过命令行来登陆 Heroku

$ heroku login

创建Heroku应用

要用Heroku注册一个新应用，需要在应用程序根目录下使用apps:create子命令，并将应用程序名称作为唯一参数传递：

$ heroku apps:create go-mega
Creating ⬢ go-mega... done
https://go-mega.herokuapp.com/ | https://git.heroku.com/go-mega.git

创建 Heroku 数据库

Heroku 只有Postgres数据库是免费的，Mysql是收费的，所以我们还是创建 Postgres 数据库

$ heroku addons:add heroku-postgresql:hobby-dev
Creating heroku-postgresql:hobby-dev on ⬢ go-mega... free
Database has been created and is available
 ! This database is empty. If upgrading, you can transfer
 ! data from another database with pg:copy
Created postgresql-angular-82467 as DATABASE_URL
Use heroku addons:docs heroku-postgresql to view documentation

初始化数据

$ heroku config
=== go-mega Config Vars
DATABASE_URL: ******************

针对Heroku做些代码优化

由于Heroku采用的是 Configvar 的设置环境变量的方式，而且我们把 config.yml git ignore了，所以加入 os.Getenv 的方式去获取Configvar的环境变量，包括 DBTYPE, EMAIL相关，以及Heroku postgres 提供的 DATABASE_URL

config/g.go

package config

import (
	"fmt"
	"log"
	"os"
	"strconv"

	"github.com/spf13/viper"
)

func init() {
	projectName := "go-mega"
	dbType := GetDBType()
	log.Println("OS DBTYPE:", dbType)

	if IsHeroku() {
		log.Println("Get Env from os.env")
	} else {
		log.Println("Init viper")
		getConfig(projectName)
	}
}

func getConfig(projectName string) {
	viper.SetConfigName("config") // name of config file (without extension)

	viper.AddConfigPath(".")                                                // optionally look for config in the working directory
	viper.AddConfigPath(fmt.Sprintf("$HOME/.%s", projectName))              // call multiple times to add many search paths
	viper.AddConfigPath(fmt.Sprintf("/data/docker/config/%s", projectName)) // path to look for the config file in

	err := viper.ReadInConfig() // Find and read the config file
	if err != nil {             // Handle errors reading the config file
		panic(fmt.Errorf("Fatal error config file: %s", err))
	}
}

// GetMysqlConnectingString func
func GetMysqlConnectingString() string {
	usr := viper.GetString("mysql.user")
	pwd := viper.GetString("mysql.password")
	host := viper.GetString("mysql.host")
	db := viper.GetString("mysql.db")
	charset := viper.GetString("mysql.charset")

	return fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?charset=%s&parseTime=true&loc=Local", usr, pwd, host, db, charset)
}

// GetHerokuConnectingString func
func GetHerokuConnectingString() string {
	return os.Getenv("DATABASE_URL")
}

// GetSMTPConfig func
func GetSMTPConfig() (server string, port int, user, pwd string) {
	if IsHeroku() {
		server = os.Getenv("MAIL_SMTP")
		port, _ = strconv.Atoi(os.Getenv("MAIL_SMTP_PORT"))
		user = os.Getenv("MAIL_USER")
		pwd = os.Getenv("MAIL_PASSWORD")
		return
	}

	server = viper.GetString("mail.smtp")
	port = viper.GetInt("mail.smtp-port")
	user = viper.GetString("mail.user")
	pwd = viper.GetString("mail.password")
	return
}

// GetServerURL func
func GetServerURL() (url string) {
	if IsHeroku() {
		url = os.Getenv("SERVER_URL")
		return
	}
	url = viper.GetString("server.url")
	return
}

// GetDBType func
func GetDBType() string {
	dbtype := os.Getenv("DBTYPE")
	return dbtype
}

// IsHeroku func
func IsHeroku() bool {
	return GetDBType() == "heroku"
}

修改ConnectToDB函数，支持postgres的数据库形式

model/g.go

...

// ConnectToDB func
func ConnectToDB() *gorm.DB {
	if config.IsHeroku() {
		return ConnectToDBByDBType("postgres", config.GetHerokuConnectingString())
	}
	return ConnectToDBByDBType("mysql", config.GetMysqlConnectingString())
}

// ConnectToDBByDBType func
func ConnectToDBByDBType(dbtype, connectingStr string) *gorm.DB {
	log.Println("DB Type:", dbtype, "\nConnet to db...")
	db, err := gorm.Open(dbtype, connectingStr)
	if err != nil {
		panic("Failed to connect database")
	}
	db.SingularTable(true)
	return db
}

cmd/db_init/main.go

    ...
    _ "github.com/jinzhu/gorm/dialects/postgres"
    ...

main.go 与数据初始化无关，不过最后部署还是要做响应的调整

main.go

    ...
    _ "github.com/jinzhu/gorm/dialects/postgres"
    ...

    port := os.Getenv("PORT")
	log.Println("Running on port: ", port)
	http.ListenAndServe(":"+port, context.ClearHandler(http.DefaultServeMux))

数据init

$ export DATABASE_URL=postgres://xxxxxxx
$ export DBTYPE=heroku

$ go run cmd/db_init/main.go

然后我们会发现数据初始化就完成了，让我们确认下

# 安装 psql
$ brew install postgresql

$ heroku pg:psql
$ DATABASE=> select * from post;
id | user_id |              body               |           timestamp
----+---------+---------------------------------+-------------------------------
  1 |       1 | Beautiful day in Portland!      | 2018-10-23 07:13:30.664214+00
  2 |       2 | The Avengers movie was so cool! | 2018-10-23 07:13:36.118051+00
  3 |       2 | Sun shine is beautiful          | 2018-10-23 07:13:38.502164+00

# 退出
$ DATABASE=> \q

本小节 Diff

部署 Heroku

设置Config Vars

可以访问 https://dashboard.heroku.com/apps/go-mega/settings dashboard 的Config Vars 进行设置

也可以通过heroku cli

$ heroku config:set DBTYPE=heroku

# 设置root url
$ heroku config:set SERVER_URL=https://go-mega.herokuapp.com

# 设置mail
$ heroku config:set MAIL_SMTP=smtp.zoho.com
$ heroku config:set MAIL_SMTP_PORT=587
$ heroku config:set MAIL_USER=your_username
$ heroku config:set MAIL_PASSWORD=your_password

# 查看config
$ heroku config

Procfile

Procfile

web: go-mega-code

现在我们可以通过 heroku local来在本地查看应用

$ heroku local

Go dep

目前我们的代码和配置都已经完成了，不过部署 heroku 还需要我们提供依赖，我们这里使用 Godep

可以参照Go Dependencies via Godep

$ go get -u github.com/tools/godep
$ godep save ./...

# 结果可以看见多了两个文件夹: vendor/ 和 Godeps/

Push heroku

# Remote add heroku
$ heroku git:remote -a go-mega
set git remote heroku to https://git.heroku.com/go-mega.git
$ git remote -v
heroku	https://git.heroku.com/go-mega.git (fetch)
heroku	https://git.heroku.com/go-mega.git (push)
origin	git@github.com:bonfy/go-mega-code.git (fetch)
origin	git@github.com:bonfy/go-mega-code.git (push)

# push branch to heroku master
$ git push heroku 14-Deployment-On-Heroku:master

现在访问 https://go-mega.herokuapp.com/ 就能看见 Demo 了

Notice: Heroku 绑定域名要收费，所以这里就不绑定域名了

本小节 Diff

Links

目录

上一节: 13-Javascript-Magic

下一节: 15-Deployment-On-Linux

15-Deployment On Linux

在本章，我将会把应用部署到Linux服务器, 与上一章相比，这种是比较传统的方式

本章的GitHub链接为： Source, Diff, Zip

Vultr

当提到“传统托管”时，意思是应用是手动或通过原始服务器机器上的脚本安装部署的。 该过程涉及安装应用程序、其依赖项和生产规模的Web服务器，并配置系统以确保其安全。

当你要部署自己的项目时，要问的第一个问题是在哪找服务器。 目前有很多经济的托管服务。 只需每月2.5 - 5美元，Vultr,Digital Ocean和Linode

这里我选择的是 Vultr 创建了 CentOS 7 的linux 服务器

广告: 这里大家可以通过这个地址注册 Vultr，我会得到 10 dollar 的奖励，如果想搞个VPS自己玩玩的同学可以考虑下，还能搭个那啥用(自己体会) 链接: https://www.vultr.com/?ref=7079936

ssh 免密登陆

$ ssh-keygen -t rsa -C  'your email'

$ scp ~/.ssh/id_rsa.pub username@hostname:~/ # 将公钥文件复制至ssh服务器
$ ssh username@hostname # 使用用户名和密码方式登录至ssh服务器
$ mkdir .ssh  # 若.ssh目录已存在，可省略此步
$ cat id_rsa.pub >> .ssh/authorized_keys  # 将公钥文件id_rsa.pub文件内容追加到authorized_keys文件

以后登录ssh 只要输入 ssh username@hostname 即可不用输入密码啦

安装mysql

$ ssh username@hostname

# Install
$ yum -y install mariadb mariadb-serve

# Start mariadb
$ sudo systemctl start mariadb
$ sudo systemctl enable mariadb

# Securing the MariaDB Server
# Set Password
$ sudo mysql_secure_installation

# crate database
$ mysql -uroot -p
$ MariaDB > create database go-mega;

部署应用

与 Python 相比，Go 部署应用还是算比较方便的，因为它可以编译

Makefile

为了方便部署，我们这边建一个 Makefile 方便我们部署

Makefile

.PHONY: clean upload-vps

# Put your server ip or nickname here
host = ny

clean:
	@echo "clean..."
	@rm -rf app/
	@echo "success clean"
build-db:
	@echo "run build db..."
	@env GOOS=linux go build -o app/db cmd/db_init/main.go
	@echo "success build db"
build-server:
	@echo "begin build server..."
	@env GOOS=linux go build -o app/server main.go
	@echo "success build server"
upload-vps: clean build-db build-server
	@echo "begin run vps upload..."
	@echo "Host: $(host)"
	@scp -r app root@$(host):go-mega
	@scp config.yml root@$(host):go-mega/
	@scp -r templates root@$(host):go-mega/templates
	@echo "success vps upload"

注意 上面的 Host 修改成 你自己的 Host ip address 或者 Host 别名

然后我们就比较方便了运行

$ make upload-vps

就可以了，我们可以登录到我们的vps上查看，就多了一个 go-mega 的文件夹了

这里简要做个说明

clean: 清楚上次的编译二进制文件

build-db: 编译 cmd/db_init/main.go -> app/db

build-server: 编译 main.go -> app/server

upload-vps: 将 /app 和 templates 文件夹上传到 vps的 ~/go-mega 里

运行

$ ssh ssh username@hostname

$ cd go-mega

# 设置环境变量 以及 config.yml
$ vi config.yml # 按照config.yml.sample 设置
$ export PORT=8000

# 初始化数据库
$ ./db
# 启动应用
$ nohup ./server &

# 这个时候服务已经启动了，这个时候如果想要访问应用可以使用 nginx反代到 80端口 或者 防火墙开放你设置的端口
# 最好是使用nginx，不过这里为了演示，我们开放下端口
$ firewall-cmd --zone=public --add-port=8000/tcp

这个时候我们访问 http://xxx.xxx.xxx.xxx:8000 就可以访问你刚才发布的应用了, 大功告成

这里暂时不是HTTPS的，如果设置HTTPS，需要你自己申请HTTPS证书和配置nginx，这方面比 Heroku 还是麻烦点

Links

目录

上一节: 14-Deployment-On-Heroku

下一节: 16

16-Summary

前两章部署之后，我们的程序其实就能顺利在服务器上运行，接受用户使用了

我们也终于来到了本系列的最后一章，让我们来总结一下

知识点

本系列教程使用 Go 语言从无到有实现一个类似初级版 Twitter 的 web网站，主要涉及到了以下的知识点:

表单

模板

数据库

路由

登陆验证

MVC

分页

Mail

部署

Ajax

Jwt

与原教程 Flask-Mega 相比，我们主要少了 全文搜索、Docker、用户通知 这几个章节

其中 Docker 对于 Go 程序来说还是很方便的，因为 Go 可以直接编译成二进制程序，所以一种写 Dockerfile 的思路就是先编译 然后 COPY 进 container 就行了

全文搜索 其实是对 Elasticsearch 的依赖，其实 Go 也有响应的库支持，这里不引入只是一开始就只打算加一个数据库，不想涉及更多的依赖，增加程序的复杂性

用户通知其实是对 Ajax 和 API 的更深入的一种实现，其实有了前面的知识，还是很容易能完成这个功能，如果说有点难度，只是 Go 的Template 没有Flask 那么灵活而已

总结

这是我第一次系统性地完成编写一套系列教程

由于有个Flask-Mega的模板在，在章节设计方面已经节省了好多时间，不过 代码 和 文字方面还是要花费不少的精力，基本上3天一章已经是极限（毕竟还是要上班养家糊口的）

通过编写这个系列教程，也算是我自己重新梳理了下知识结构，相信有这个教程在，也避免了我写完就忘的神奇特性（以前也写过好多程序，过段时间回首，知识剩下的就不多了）

好记性不如烂笔头，写下来方便自己，如果还能方便他人，那真的是再好不过了。

后续

后续首要第一件事情就是休息，先休息一段时间

然后在下面几方面好好整理，写点东西

Test

Restful API

GraphQL

支持我

如果您感觉有收获，欢迎给我打赏，以激励我输出更多优质内容，感谢支持！