第一次用Web框架开发网站 · Golang

我所管理的一个群实行的是问卷考核制,要进群的人必须先填写一份问卷,由管理员批改后发送邮件通知结果。我们有一个专门用于发送这个邮件的后台,管理员仅需要填写对方的姓名、邮箱、分数、是否通过等信息,点击发送即可。

老的系统是基于WordPress + Contact form 7搭建的,现由于要与我们群内的机器人对接,所以重新写了一个新的系统。既然我来写,自然用最熟悉的Go语言,顺便学习一些Go语言用于Web应用的框架,比如之前了解过却没有真正使用过的Gin和Gorm,在这次开发中也是好好体验了一番。


接下来还是先简单了解一下Gin

Gin is a HTTP web framework written in Go (Golang). It features a Martini-like API with much better performance – up to 40 times faster. If you need smashing performance, get yourself some Gin.

总之是个Web框架,合理使用减轻开发的负担,用更少的代码写出更好的程序。

嗯,确实很简单。


再来看看Gorm

The fantastic ORM library for Golang, aims to be developer friendly (v2 is under development, PR based on master branch won’t be accepted)

首先要说说ORM是什么,面向对象编程把一切实体都看作对象(Object),而关系型数据库则按照关系(Relation)联系数据。直到有人提出关系可以用对象来表达,于是就可以用面向对象的语法,来操作关系型数据库了。这就是对象/关系映射(Object/Relational Mapping,简称ORM)。下面就是这个映射关系:

面向对象 关系型数据库
类(Class) 表(Table)
对象(Object) 记录(Row)
属性(Attribute) 记录值(Column)

假设有一个数据库如下

ID Name Score
0 Chen 60
1 Limz 59

原本查询语句要这么写:

var name string
var score int
db.QueryRow("SELECT (Name,Score) WHERE ID = ?", 0).Scan(&name, &score)
fmt.Printf("%s's score is %d", name, score)

而如果用ORM,则不需要自己编写SQL语句:

var s Student
db.Where("ID = ?", 0).First(&s)
fmt.Printf("%s's score is %d", s.Name, s.Score)

看到这里ORM的好处不言而喻(如果还不明白,之后就明白了)。而Gorm就是Go语言里超棒的一款ORM库,我们的系统将基于它开发。


现在就明白了,Web框架用Gin,数据库一直在用MySQL,操作数据库用Gorm。而要做的功能还没给大家讲清楚。

我们的最终目的是允许管理员发送邮件给想进群的人,方式是让管理员填写一份邀请表单并提交给服务器,服务器根据邀请表单和预先写好的邀请邮件模版自动生成邮件,并发送给想进群的人

也就是说,我们的Web服务器的核心功能是接收管理员发送的POST请求,并把数据传给模版引擎html/template,生成最终的邮件并发送。问题是,如何发送邮件?

经过一番研究,发现用一个第三方库gopkg.in/gomail.v2可以快速简单地解决发送邮件的问题,虽然标准库也有net/stmp用于收发邮件,但是文档中赫然写着:

The smtp package is frozen and is not accepting new features. Some external packages provide more functionality. See:

https://godoc.org/?q=smtp

而且某群友折腾半天,仍然无法使标准库正常工作,遂放弃。

func sendMail(mail string){
    m := gomail.NewMessage()
    m.SetHeader("From", "alex@example.com")
    m.SetHeader("To", "bob@example.com")
    m.SetHeader("Subject", "主题")
    m.SetBody("text/html", "Hello <b>Bob</b>!")

    d := gomail.NewDialer("smtp.example.com", 587, "user", "123456")

    // Send the email to Bob
    if err := d.DialAndSend(m); err != nil {
        panic(err)
    }
}

这个库用法可以说是简单明了,先构造一条Message,然后用Dialer发送出去。非常简单易用,用标准库时出现的奇奇怪怪的问题也都解决掉了。


发送邮件的问题解决掉了,但是邮件从哪来呢?

graph LR
    temp{{模版}} -->|解析| render[模版引擎]
    form[/表单/] -->|输入| render[模版引擎]
    render -->|渲染| mail(邮件)

这里当然还是选用标准库的模版引擎html/template,它是text/template的升级版,为html提供了一些特别的处理。模版引擎的使用方法是,启动时预先解析模版邮件,来一份表单执行一次模版,线性复杂度。

var mailTmpl = template.Must(template.ParseFiles("tmpl/mail.tmpl"))

func render(data MailData) (string, error) {
    var buf strings.Builder
    if err := mailTmpl.Execute(&buf, data); err != nil {
        return "", err
    }
    return buf.String(), nil
}

至此,关于提交表单之后的全部操作已经介绍完了,接下来到了搭建基础Web服务的时候了。Gin的使用非常简单:

func main() {
    r := gin.Default()
    // 配置路由
    r.GET("/invite", func(c *gin.Context) {
        // 操作界面
    })
    r.POST("/mail", func(c *gin.Context) {
        // 发送接口
    })
    // 运行
    r.Run()
}

目前让其接收两种请求,在/invite下的GET请求,提供让管理员访问的操作页面,展示一张表单让管理员填写。填写完毕后表单会以POST请求被发送到/mail路径,/mail会接收该表单,如前所述渲染并发送邮件。

/invite

管理员能访问到的页面,自然是要用html编写。再辅以轻度的css样式,即可达到简洁而又实用,清新而不失优雅的效果。ʕ •ᴥ•ʔ

<!DOCTYPE html>
<html lang="zh">

<head>
    <title>喵喵公馆邀请邮件发送页面</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>/* 此处省略很多很多css */</style>
</head>

<body>
    <form action="mail" method="post">
        <table><!-- 此处省略很多很多<input> --></table>
    </form>
</body>

</html>

其实从原理上讲,不需要任何Javascript即可实现所要的功能。不过最后实际做出来的版本,使用了XMLHttpRequest技术,不仅可以防止意外重复提交,还能让提交的体验更丝滑ε-(´∀`; )。

将这个文件保存为invite.tmpl,并保存到所有模版文件所在的目录tmpl/下,然后在main函数内加载所有模版:

r.LoadHTMLGlob("tmpl/*.tmpl")

最后,将/invite的访问全部用这个模版来处理:

r.GET("/invite", func(c *gin.Context) {
    c.HTML(http.StatusOK, "invite.tmpl", nil)
})

如此一来,管理员就已经可以正常访问/invite页面了,但是他们会吃惊地发现,即使再怎么点提交按钮,也没法成功发送邮件。为了不让管理员们生气,赶紧来把发邮件的接口写好吧!

/mail

其实说难也不难,只要把前面说的渲染邮件那套代码放进来就可以了

type Mail struct {
    Name       string `form:"name"`
    Mail       string `form:"mail"`
    Score      string `form:"score"`
    Version    string `form:"version"`    // 问卷版本
    Stat       string `form:"status"`     // 是否通过 Pass/Remain/Fail
    Postscript string `form:"postscript"` // 附言
    Sender     string `form:"sender"`     // 管理员名
    Date       time.Time
}
r.POST("/mail", func(c *gin.Context) {
    // 解析表单
    var data Mail
    if err := c.Bind(&data); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "status": "error",
            "error": "bind form error",
        })
        return
    }
    data.Date = time.Now()
    // 渲染邮件
    if err := mailTmpl.Execute(&buf, data); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "status": "error",
            "error": "execute template error",
        })
        return
    }
    // 发送邮件
    if err := sendMail(buf.String()); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "status": "error",
            "error": "send e-mail error",
        })
        return
    }
    c.JSON(http.StatusOK, gin.H{
        "status": "ok",
    })
})

小结

至此,这个系统已经初步完成,但也仅仅处于能用的地步,而安全则半点都谈不上,因为任何人都可以访问/invite来发邮件。后面到文章会讲到如何给这个系统加入授权系统。得益于Gin的优秀设计,加入一个中间件在/invite和/mail之前用于鉴权轻而易举。

敬请期待。