图源:wallpapercave.com
通过一系列文章,我介绍了如何用Go语言构建一个Web应用,准确的说是一个网站。事实上并非所有的Web应用都是以网站的形式存在,其中相当一部分是Web Service,相比前者,后者的应用范围更广泛,它的前端可能是纯Js编写的网站前端,也可能是移动APP,甚至是另一个Web应用。
所以这篇文章将介绍如何构建一个Web Service。
这里的Web Service概念和Apache之类的有所不同,它指那些通过API方式提供服务的Web应用。
在说明Web Service之前,要先说明两种流行的文本传输格式:XML和JSON,事实上大部分Web Service都会使用这两者其中之一作为API的载体。
来看一个典型的XML文本:
<article id="1" uid="1">
<Content>this is a art's content.Content>
<comments>
<comment id="1" uid="1">first comment content.comment>
<comment id="2" uid="1">second comment content.comment>
<comment id="3" uid="2">third comment content.comment>
comments>
article>
Content标签中的乱码是对
'
符号的转义。
XML本身并不复杂,和HTML类似,都是由一系列标签组成。关于XML的相关定义这里不过多解释,直接看如何用Go语言解析:
package main
import (
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"os"
"strings"
)
type Article struct {
XMLName xml.Name `xml:"article"`
Id int `xml:"id,attr"`
Content string `xml:content`
Comments []Comment `xml:"comments>comment"`
Uid int `xml:"uid,attr"`
}
func (a *Article) String() string {
var comments []string
for _, c := range a.Comments {
comments = append(comments, c.String())
}
scs := strings.Join(comments, ",")
return fmt.Sprintf("Article(Id:%d,Content:'%s',Comments:[%s],Uid:%d)", a.Id, a.Content, scs, a.Uid)
}
type Comment struct {
XMLName xml.Name `xml:"comment"`
Id int `xml:"id,attr"`
Content string `xml:",chardata"`
Uid int `xml:"uid,attr"`
}
func (c *Comment) String() string {
return fmt.Sprintf("Comment(Id:%d,Content:'%s',Uid:%d)", c.Id, c.Content, c.Uid)
}
func main() {
fopen, err := os.Open("art.xml")
if err != nil {
panic(err)
}
defer fopen.Close()
content, err := ioutil.ReadAll(fopen)
if err != nil && err != io.EOF {
panic(err)
}
art := Article{}
err = xml.Unmarshal(content, &art)
if err != nil {
panic(err)
}
fmt.Println(art.String())
}
Go用于处理XML格式的包为encoding/xml
。
和在Go语言编程笔记16:存储数据 - 魔芋红茶’s blog (icexmoon.xyz)中介绍的ORM类似,要将一个XML文本解析到Go的结构体,需要建立从XML到结构体的映射关系,这种映射关系同样体现为结构体的字段标签。
所谓的“字段标签”实际上就是结构体字段后用特殊单引号标注的部分。
字段标签有这么几种:
xml:""
,指代当前标签中的名称为tag_name
的子标签的值。xml:",attr"
,指代当前标签的名称为attr_name
的属性。xml:",innerxml"
,指代当前标签包含的XML文本。xml:",chardata"
,指代当前标签的值。xml:"a>b>c"
,指代当前标签下包含的a
标签包含的b
标签包含的c
标签,使用此标注可以直接让字段跨越几个层级对应到某个或某几个字标签。比较特别的是,一般情况下解析器会使用结构的名称来匹配标签,比如用结构体Article
匹配标签,但如果结构体名称和标签不一致,就需要通过给结构体添加一个额外字段
XMLName
来指定对应的标签:
type Article struct {
XMLName xml.Name `xml:"article"`
...
}
XMLName
的类型是xml.Name
,字段标签是xml:"
。
通过字段标签构建好映射关系后就简单了,从文件或者数据流读取数据到字符串或者字节序列,然后调用xml.Unmarshal
函数进行解码即可。
大多数情况下用这种方式解析XML文本都是没有问题的,但有时候对于某些内容巨大的XML文件或者字节流,这样处理就不合适了,可能完整读取XML到内存都会是一项艰巨的任务。
所以xml
包还提供“逐句解析”的选项,这样做可以在节省内存的前提下解析大容量XML文本:
...
func main() {
fopen, err := os.Open("art.xml")
if err != nil {
panic(err)
}
defer fopen.Close()
d := xml.NewDecoder(fopen)
var comments []Comment
for {
token, err := d.Token()
if err == io.EOF {
//xml解析完毕
break
}
if err != nil {
//解析出错
panic(err)
}
switch node := token.(type) {
case xml.StartElement:
if node.Name.Local == "comment" {
cmmt := Comment{}
d.DecodeElement(&cmmt, &node)
comments = append(comments, cmmt)
}
}
}
art := Article{}
art.Comments = comments
fmt.Println(art.String())
}
主要分为这几个步骤:
xml.NewDecoder
从文件或数据流创建一个解析器。for
循环和d.Token()
从解析器挨个获取XML节点。switch...case
语句将接口类型的XML节点向下转型,判断是否为开始标签。如果是,且是需要处理的标签,就调用d.DecodeElement
对具体标签进行处理。将Go语言中的变量编码为XML文本的方式相当于解析的“逆过程”,主要工作量依然是通过结构的字段标签来说明映射关系,之后的编码过程就很容易:
func main() {
art := Article{
Id: 1,
Content: "this is a art's content.",
Uid: 1,
Comments: []Comment{
{
Id: 1,
Content: "first comment content.",
Uid: 1,
},
{
Id: 2,
Content: "second comment content.",
Uid: 1,
},
{
Id: 3,
Content: "third comment content.",
Uid: 2,
},
},
}
rest, _ := xml.Marshal(art)
fopen, err := os.OpenFile("art.xml", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
fopen.Write(rest)
}
如果观察生成的文件art.xml
就会发现是一长串单行字符串,可读性很差。当然这并不影响XML相关程序的正常解析,但如果需要人为排查XML问题就会造成一些困难。除了借助IDE的格式化工具等直接格式化XML文件以外,也可以直接用Go生成可读性良好的XML文本:
...
func main() {
...
rest, _ := xml.MarshalIndent(art, "", "\t")
fopen, err := os.OpenFile("art.xml", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
fopen.Write(rest)
}
使用MarshalIndent
函数可以指定输出XML文本时添加的前缀字符和缩进符号,在示例中指定了\t
作为缩进。这里的缩进符号会随着XML节点的层级来调整使用的个数,所以最终的输出结果是可读性强的多层级样式:
<article id="1" uid="1">
<Content>this is a art's content.Content>
<comments>
<comment id="1" uid="1">first comment content.comment>
<comment id="2" uid="1">second comment content.comment>
<comment id="3" uid="2">third comment content.comment>
comments>
article>
但现在还有一个问题,生成的XML文本并不完整,没有首行的XML声明(包含XML版本信息的那行文字)。
要添加XML首行也很容易:
...
func main() {
...
fopen.Write([]byte(xml.Header))
fopen.Write(rest)
}
常量xml.Header
就包含了XML首行的内容。
和XML解析类似,对于内容巨大的XML文本,同样可以进行“逐步编码”以节省内存开销:
...
func main() {
...
fopen, err := os.OpenFile("art.xml", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
fopen.Write([]byte(xml.Header))
encoder := xml.NewEncoder(fopen)
encoder.Indent("", "\t")
encoder.Encode(art)
}
对于编码来说,并不需要像解析那样自己实现逻辑,只需要使用xml.NewEncoder
从文件或者数据流中创建编码器,然后使用编码器进行编码即可。
虽然XML的功能非常强大,甚至可以通过给XML文本编写DTD文本的方式,让XML具有“自验证”的功能,甚至自动生成XML的结构说明文件。
但XML有个缺点,即使是携带一小部分内容,也需要用很多额外内容来填充各种标签结构,这就让整个文本中的有效信息含量偏低,意味着传输效率很低。
所以对于一些小型应用,使用XML作为API载体并不是一个好主意。
相较而言,JSON就友好的多,结构简单意味着容易构建和有效信息含量高,以及较高的传输效率。
看一个典型的JSON文本:
{
"id": 1,
"content": "this is a art's content.",
"contents": [
{
"id": 1,
"content": "first comment content.",
"uid": 1
},
{
"id": 2,
"content": "second comment content.",
"uid": 1
},
{
"id": 3,
"content": "third comment content.",
"uid": 2
}
],
"uid": 1
}
JSON比XML简单的多,并没有属性之类的概念,都是由键值对构成。
解析JSON的方式和XML几乎完全一致,只不过JSON的字段标签比XML要简单的多:
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
)
type Article struct {
Id int `json:"id"`
Content string `json:"content"`
Comments []Comment `json:"contents"`
Uid int `json:"uid"`
}
func (a *Article) String() string {
var comments []string
for _, c := range a.Comments {
comments = append(comments, c.String())
}
scs := strings.Join(comments, ",")
return fmt.Sprintf("Article(Id:%d,Content:'%s',Comments:[%s],Uid:%d)", a.Id, a.Content, scs, a.Uid)
}
type Comment struct {
Id int `json:"id"`
Content string `json:"content"`
Uid int `json:"uid"`
}
func (c *Comment) String() string {
return fmt.Sprintf("Comment(Id:%d,Content:'%s',Uid:%d)", c.Id, c.Content, c.Uid)
}
func main() {
fopen, err := os.Open("art.json")
if err != nil {
panic(err)
}
defer fopen.Close()
content, err := ioutil.ReadAll(fopen)
if err != nil {
panic(err)
}
art := Article{}
err = json.Unmarshal(content, &art)
if err != nil {
panic(err)
}
fmt.Println(art.String())
}
同样的,如果是读取内容很大的JSON文本,需要使用额外技巧:
...
func main() {
fopen, err := os.Open("art.json")
if err != nil {
panic(err)
}
defer fopen.Close()
decoder := json.NewDecoder(fopen)
art := Article{}
err = decoder.Decode(&art)
if err != nil {
panic(err)
}
fmt.Println(art.String())
}
编码JSON文本的方式同XML几乎一致:
...
func main() {
art := Article{
Id: 1,
Content: "this is a art's content.",
Uid: 1,
Comments: []Comment{
{
Id: 1,
Content: "first comment content.",
Uid: 1,
},
{
Id: 2,
Content: "second comment content.",
Uid: 1,
},
{
Id: 3,
Content: "third comment content.",
Uid: 2,
},
},
}
rest, _ := json.Marshal(art)
fopen, err := os.OpenFile("art.json", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
fopen.Write(rest)
}
同样的,默认使用Marshal
函数输出的是阅读不友好的单行字符串形式,要输出阅读友好的JSON文本:
...
func main() {
...
rest, _ := json.MarshalIndent(art, "", "\t")
fopen, err := os.OpenFile("art.json", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
fopen.Write(rest)
}
如果要输出内容庞大的JSON文本:
...
func main() {
...
fopen, err := os.OpenFile("art.json", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer fopen.Close()
encoder := json.NewEncoder(fopen)
encoder.SetIndent("", "\t")
encoder.Encode(art)
}
现在我们可以来学习如何创建一个Web Service了,方便起见,这里在Go语言编程笔记16:存储数据 - 魔芋红茶’s blog (icexmoon.xyz)中构建的简易bbs基础上来构建,并使用JSON作为API的载体。
API采用REST(Representational State Transfer)的方式来构建。
REST是一种思想,即将URL请求看做对资源的访问。
对于传统的URL,获取某个帖子的详情的URL可能长这样:
http://127.0.0.1:8080/article?id=2
删除一个帖子的URL可能长这样:
http://127.0.0.1:8080/del_art?art_id=6
添加一个帖子的URL可能长这样:
http://127.0.0.1:8080/add_article
传统URL是在URL中使用动词+名词的方式来展现意图,一个URL就表示了一个确定性的行为。而HTTP method几乎没有任何意义,仅仅与表单提交时具体报文体的编码方式相关。
如果使用REST构建URL,获取帖子详情的URL可能是:
GET http://127.0.0.1:8080/api/article/2
删除帖子的URL:
DELETE http://127.0.0.1:8080/api/article/1
添加帖子的URL:
POST http://127.0.0.1:8080/api/article
REST的关键在于,URL本身代表一种可以通过HTTP访问的资源,而结合HTTP method就可以知道是要添加、删除还是获取相应的资源,这样的URL无疑要简洁许多。
因为HTML规范规定只有GET和POST是必须实现的HTTP method,其它方法不是,所以HTML本身无法直接使用REST方式构建的URL,只能借助Js等其他前端编程语言实现。
因为这里使用REST构建Web Service的API,且要保留原有的网站功能,并且还要从URL中解析参数,所以使用在Go语言编程笔记13:处理器 - 魔芋红茶’s blog (icexmoon.xyz)中提到的第三方多路复用器httprouter
来添加对API的路由支持。
...
func main() {
...
apiRouter := httprouter.New()
http.Handle("/api/", apiRouter)
apiRouter.POST("/api/login", api.ApiLogin)
apiRouter.GET("/api/articles", api.ApiLoginCheck(api.ApiAllArticles))
apiRouter.GET("/api/article/:id", api.ApiLoginCheck(api.ApiArticleDetail))
apiRouter.DELETE("/api/article/:id", api.ApiLoginCheck(api.ApiDelArticle))
apiRouter.POST("/api/article", api.ApiLoginCheck(api.ApiAddArticle))
http.ListenAndServe(":8080", nil)
}
网站通常使用Cookie/Session机制来作为身份验证方式,但这点在Web Service上往往行不通。因为Web Service的客户端是多种多样的,可能并没有完整的Cookie机制(比如命令行工具curl
)。
所以Web Service更常见的做法是使用“访问令牌”来作为身份验证机制。
简单的来说,就是在用户登录后返回给客户端一个特殊的字符串作为“访问令牌”,这个访问令牌本身包含用户ID、令牌有效期等关键信息(非明码,一般是MD5或其它形式)。客户端之后的每次请求都会在信息上附带这个访问令牌,服务端以此来验证用户是否有效。
访问令牌的生成方式是多种多样的,有很多成熟的第三方包可以使用,这里我构建了一个最简单的访问令牌:
package api
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/icexmoon/go-notebook/ch17/rest-xml/model"
)
const SECURITY_TOKEN = "stn"
type Token struct {
Id int `json:"id"`
Expire time.Time `json:"expire"`
SecCode string `json:"scode"`
}
//生成安全码
func (t *Token) GetSecurityCode() (sc string, err error) {
user := model.User{Id: t.Id}
user.Get()
sc = strconv.Itoa(t.Id) + user.Password + t.Expire.Format("2006-01-02 15:04:05") + SECURITY_TOKEN
hash := md5.New()
hash.Write([]byte(sc))
sc = hex.EncodeToString(hash.Sum(nil))
return
}
//生成访问令牌 string token
func (t *Token) String() (st string, err error) {
t.SecCode, err = t.GetSecurityCode()
if err != nil {
return
}
jsonBytes, err := json.Marshal(t)
if err != nil {
return
}
st = base64.StdEncoding.EncodeToString(jsonBytes)
return
}
//从string token解析生成Token
func (t *Token) Parse(token string) (err error) {
bBytes, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return
}
err = json.Unmarshal(bBytes, t)
return
}
//判断是否为有效token
func (t *Token) Validate() error {
//过期检查
if t.Expire.Before(time.Now()) {
return errors.New("token is out of date, please login again")
}
//安全码检查
sc, err := t.GetSecurityCode()
if err != nil {
fmt.Println(err)
return err
}
if t.SecCode == sc {
return nil
}
return errors.New("token is invalid, please login again")
}
//获取一个新的Token
func NewToken(id int) *Token {
token := Token{Id: id}
//有效期设置为1天
token.Expire = time.Now().Add(24 * time.Hour)
return &token
}
访问令牌Token
本身包含三个属性:
Id
,代表当前登录的用户id。Expire
,代表令牌的过期时间。SecCode
,安全码,确保访问令牌中的信息不会被客户端篡改。安全码的生成方式是用户ID+令牌过期时间+用户密码+安全码Token的MD5值。
这样做的好处是安全码本身包含了用户密码,但没有暴露用户密码,且可以用于验证用户ID和令牌过期时间有没有被篡改。且可以在服务端安全码Token泄露后换一个安全码Token。
有了这样的安全码后,就可以用客户端传过来的安全码结合服务端生成的安全码进行比对,如果一致就说明访问令牌正确,登录身份有效。且因为令牌中包含过期时间,可以让令牌在一定时间后失效,让用户重新登录获取令牌。
事实上这样的令牌可以直接以JSON的形式在客户端和服务端之间传递,但考虑到大多数令牌都是以某种编码后的字符串方式存在,这里也将其JSON并base64后进行传递。
...
func getParam(r *http.Request, param interface{}) error {
len := r.ContentLength
bodyBytes := make([]byte, len)
_, err := r.Body.Read(bodyBytes)
// fmt.Println(string(bodyBytes))
if err != nil && err != io.EOF {
return err
}
err = json.Unmarshal(bodyBytes, param)
if err != nil {
return err
}
return nil
}
func ApiLogin(rw http.ResponseWriter, r *http.Request, p httprouter.Params) {
param := struct {
Data struct {
Name string `json:"name"`
Password string `json:"password"`
} `json:"data"`
}{}
err := getParam(r, ¶m)
if err != nil {
fmt.Println(err)
http.Error(rw, "login error", http.StatusInternalServerError)
return
}
n := param.Data.Name
pwd := param.Data.Password
user, ok := model.CheckLogin(n, pwd)
if !ok {
http.Error(rw, "login error", http.StatusInternalServerError)
return
}
t := NewToken(user.Id)
data := struct {
Success bool `json:"success"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}{}
data.Success = true
data.Data.Token, err = t.String()
if err != nil {
fmt.Println(err)
http.Error(rw, "login error", http.StatusInternalServerError)
return
}
jBytes, err := json.Marshal(data)
if err != nil {
fmt.Println(err)
http.Error(rw, "login error", http.StatusInternalServerError)
return
}
rw.Header().Set("Content-Type", "application/json")
rw.Write(jBytes)
}
...
登录的逻辑主要是从JSON形式的报文体中获取用户名和密码,验证密码无误后生成访问令牌并返回。
为了方便从报文体中获取参数,这里创建了一个工具函数getParam
。
可以使用多种方式测试接口,但我使用curl
的时候发现在WIndows平台下,使用cmd
或PowerShell
通过curl
发送带JSON的请求,都会在服务端解析失败,显示JSON内容格式错误,开头有一个转义符。
猜测可能是Windows平台编码的问题。
所以这里我使用ApiPost进行测试,这是一个接口调试工具,官网:
老实说这个工具的UI设计相当糟糕,很多功能要找半天,相当不人性化。但怎么说呢,有总比没有强,能用就行…
软件整体分两大块,左侧导航栏,可以创建文件夹作为项目,在项目中添加多个接口。
点击左上方可以新建接口。
在这里修改接口名称、http method和url。
右侧这里修改http编码方式,这里选择application/json
。
在这里填入请求的JSON串,点击美化
可以将其格式化(当然不影响结果)。
之后点击发送,下方就会显示结果。
这里附上我测试的请求和返回值:
{
"data": {
"name": "111",
"password": "111"
}
}
{
"success": true,
"data": {
"token": "eyJpZCI6MSwiZXhwaXJlIjoiMjAyMi0wMS0wMlQxMTo0Mzo0NS40OTgzOTYzKzA4OjAwIiwic2NvZGUiOiJlMjBkZDViYWEyMzVhNWIyNTNjNmQ4NzRmNzk2ZmEzNCJ9"
}
}
除了登录接口,我还实现了添加帖子、查看帖子、删除帖子等接口。这里不再一一说明,完整代码见:
不要在意这个目录名称,其实我是打算做两个版本,一个用XML一个用JSON,后来作罢…
我的ApiPost接口文档见:
当然还有一些瑕疵,比如其实大多数错误都可以用http status 200并在返回信息中用自定义错误码和错误信息的方式返回。还有返回的一些信息其实是Model层的格式,并不利于客户端直接使用。查看帖子详情的接口也没有包含回复内容,也没有回帖的接口等等。感兴趣的可以自行完善。
谢谢阅读。