我现在想写一个从零到一建web 网站的教程。也想留下一个好的工程模板。
基本要求 设想 麻雀虽小,五脏俱全
用户邮件或者手机注册
每个用户看到自己的内容
就做经典的 todolist 吧
让用户之间可以共享文档
前后端做好分离,后端 restful,前端 SPA
加一点简单的 gis、短信或者其他的云服务。
后端 基本要求:
有一个舒服的框架来帮助实现路由绑定和分级、数据打包。
有一个好的 orm 插件,帮助处理80%以上的数据库细节
有一个好的表单验证插件,数据合法性尽可能直接使用配置
要能方便的实现 RESTFUL,方便实现oauth。
自己实现几个中间件 选型: 我自己非常熟悉 django,BOJv3和 BOJv4花费了大量的时间学习和开发,如果使用 pinax,可以使得开发非常快。但是 django 有点重,即使写过一遍,还是会花费极多的时间来看文档。
基本要求,要么测试覆盖率90%+,要么 github 千星以上。
这一次试试 golang,工作的时候用了半年的 gin。查了很久的资料,三件事分别使用三个不同插件,剩余的尽可能直接使用 golang 的内置实现,如果不行就弃坑。
框架选择 gin,赤兔用的框架,flask 风格,路由实现还可以,比对了其他的 beego 和 Gorilla、Revel等几个比较火的,用这个比较清爽的吧。
Gorm:golang 体系里最火的 orm
validator : 一个测试覆盖率100%的项目。
前端 基本要求:
最好是 SPA,单页面应用。
使用好的打包工具与发布流程。
目前来说前端全家桶世界是三驾马车:Angular,React,Vue。 综合来说Angular最老,React人最多,Vue学习曲线最舒服。 打包工具选择 gulp 和 webpack,走在世界前列。
持续集成 基本要求:
前后端争取都把覆盖率做到85%+
放在 github,使用优雅的姿势,把三方的源码检测 icon 加进来
上线版本管理。
如果有机会,反作用于开源社区 这部分还没有想特别清楚。后期弄完了再专门写一篇文章。
开工 信仰不能变,JetBrains Gogland + Webstorm 流程类似这样不和谐
跳转到定义可以直接 cmd+click,这一个功能就能大大提高开发效率。
前端包管理使用 NPM,后端包管理使用自带的vendor。不明白我在说啥的可以看看这个:各种语言常见的包管理工具
安装 Golang 官网下一个安装包 .zshrc 里加上环境变量
1 2 3 export GOROOT=/usr/local/go/ export PATH=$PATH:/usr/local/go/bin export PATH=$PATH:$GOPATH/bin:$GOROOT/bin
正常情况看到这个就行
1 2 ➜ todolist-backend git:(master) ✗ go version go version go1.8.3 darwin/amd64
使用 vendor 管理包 大致是先要指定一个 GOPATH,然后里面建一个 src 文件夹,然后再 src 文件夹里面建立工程。
1 2 3 4 cd /tmp/test/src mkdir myproj cd myproj govendor init
这个时候出现一个vendor文件夹。
参考这个资料:vendor的简要说明
安装 Gin Gin 的官方地址是 gopkg.in/gin-gonic/gin.v1
1 govendor fetch gopkg.in/gin-gonic/gin.v1
此时 vendor 文件夹应该变了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "comment": "", "ignore": "test", "package": [ { "checksumSHA1": "UsILDoIB2S7ra+w2fMdb85mX3HM=", ... }, ... { "checksumSHA1": "hT+7DPyl8tQ96kUXW3Sr5o0Pcj0=", "path": "gopkg.in/gin-gonic/gin.v1", "revision": "e2212d40c62a98b388a5eb48ecbdcf88534688ba", "revisionTime": "2016-12-04T22:13:08Z" }, ... ], "rootPath": "todolist-backend" }
这里可以看到很多的包,很有意思,validator和我之前调研的居然是同一个。
hello gin 这个时候吧 hello world拷贝到工程目录:
1 ➜ todolist-backend git:(master) ✗ touch hello.go
内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 package main import "gopkg.in/gin-gonic/gin.v1" func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) r.Run() // listen and serve on 0.0.0.0:8080 }
运行之:
1 ➜ todolist-backend git:(master) ✗ go run hello.go
浏览器打开http://localhost:8080/ping
可以看到{"message":"pong"}
然后把 golang 最热门的 orm 工具 gorm 装上
1 2 ➜ todolist-backend git:(master) ✗ govendor fetch github.com/jinzhu/gorm ➜ todolist-backend git:(master) ✗ govendor fetch github.com/go-sql-driver/mysql
后端基础安装工作完成!
auto reload 每次改东西都重启服务很不爽,所以找到一个库gin fresh
1 2 ➜ todolist-backend git:(master) ✗ go get github.com/pilu/fresh ➜ todolist-backend git:(master) ✗ fresh
跑起来非常非常慢 查资料发现应该是依赖的 pkg 并没有编译go build very slow
简单的 RESTFUL 接口 Restful 的具体要求可以去查看我的博文
简言之:
1 2 3 4 5 6 POST todos/ GET todos/ GET todos/{id} PUT todos/{id} PATCH todos/{id} DELETE todos/{id}
架子 创建一个文件 main.go
1 ➜ todolist-backend git:(master) ✗ touch main.go
输入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package main import ( "gopkg.in/gin-gonic/gin.v1" ) func main() { router := gin.Default() v1 := router.Group("/api/v1/todos") { v1.POST("/", CreateTodo) v1.GET("/", FetchAllTodo) v1.GET("/:id", FetchSingleTodo) v1.PUT("/:id", UpdateTodo) v1.DELETE("/:id", DeleteTodo) } router.Run() }
确认数据库运行 gorm.Model 包含了基本的一些变量 “ID, CreatedAt, UpdatedAt, DeletedAt”
1 2 3 4 5 6 7 8 9 10 11 type Todo struct { gorm.Model Title string `json:"title"` Completed int `json:"completed"` } type TransformedTodo struct { ID uint `json:"id"` Title string `json:"title"` Completed bool `json:"completed"` }
注释掉 main() 里面的其他东西,只保留这一部分
1 2 3 db := Database() db.AutoMigrate(&Todo{}) defer db.Close()
此时应该会在工程目录下出现一个 gorm.db 文件。
先模仿别人的 请先阅读下面的文章,后端是在他基础上的改进!
请先阅读下面的文章,后端是在他基础上的改进!
请先阅读下面的文章,后端是在他基础上的改进!
Build RESTful API with GIN
我把它的代码在本地运行了。 好处:
提供了一个 gin 和 gorm 一起工作的样板
提供了一个增删改查的能正确运行的架子,减少踩坑的次数 问题:
并不是很标准的 restful,返回值乱包了东西。
数据接受dataform,不能根据 content-type 来自己适配
改进代码 基本要求如下: GET api/v1/todos/ 返回对象全集 POST api/v1/todos/ 返回新生成的对象 PUT api/v1/todos/id 返回被更新的对象 PATCH api/v1/todos/id 返回被更新的对象 DELETE api/v1/todos/id 返回成功提示
CreateTodo 我们希望 post 同时支持 form_data && json_data
这个部分先去 gin 框架的文档搜,发现它的实现方式是用 c.bind(&something),大致 就是把 request 数据绑定到一个结构体里面去。
model-binding-and-validation 官方这样:
1 2 3 4 type Login struct { User string `form:"user" json:"user" binding:"required"` Password string `form:"password" json:"password" binding:"required"` }
我自己把域改成类似的样子,经过 postman 的测试,这段代码是可以工作的。但测了几个数据,发现一个天坑,0 和 false 无法被 gin 的 binding接受,然后解决方案是这样: use pointer & ‘exists’
大致是说required域在结构体创建的时候会有一个默认值,程序没法知道这个值是由post_data 来的还是默认来的,因此需要使用指针来判断。
此时大概这样:
1 2 3 4 5 6 type Todo struct { gorm.Model Title string `form:"title" json:"title" binding:"required"` Priority *int `form:"priority" json:"priority" binding:"exists"` Completed *bool `form:"completed" json:"completed" binding:"exists"` }
然后,我进一步常识,发现不需要使用指针,直接把 binding 域改成 exists 就好了。
动手修改了定义模型的结构体,这样 bind 的时候就会把作用域自动绑定,另外把域修改为 int, bool, string 各一个,麻雀虽小五脏俱全:
1 2 3 4 5 6 7 type Todo struct { gorm.Model Title string `form:"title" json:"title" binding:"required"` Priority int `form:"priority" json:"priority" binding:"exists"` Completed bool `form:"completed" json:"completed" binding:"exists"` }
CreateTodo 然后这个时候我们去修改CreateTodo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func CreateTodo(c *gin.Context) { var todo Todo fmt.Println(todo) if err := c.Bind(&todo); err != nil { fmt.Println(err) c.JSON(http.StatusBadRequest, gin.H{"Data binding error" : err}) return } db := Database() defer db.Close() if err := db.Save(&todo).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"Database error" : err}) return } c.JSON(http.StatusCreated, todo) }
此时已经比较接近我想要的 CreateTodo
定制 Response 看了几个资料之后觉得这种是最好的different marshalling & unmarshalling
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 //自制一个 Response,可以自由插入元素个数 type TodoResponse struct { ID uint `json:"id" xml:"id"` Title string `json:"title" xml:"title"` Priority int `json:"priority" xml:"priority"` Completed bool `json:"completed" xml:"completed"` } func (todo Todo) Response() (interface{}){ return TodoResponse{ ID: todo.ID, Title: todo.Title, Priority: todo.Priority, Completed: todo.Completed, } } //把CreateTodo()里的返回改成自制的函数 ... c.JSON(http.StatusCreated, todo.Response()) //注释掉上面一句,取消下面一句可以返回 xml //c.XML(http.StatusCreated, todo.Response()) ...
总结 用 json marshul 自带的功能来反序列化,这样使用框架自带的 binding 功能,一次性支持多种格式。 然后序列化自己写函数来生成一个对象,然后由框架根据 header 完成序列化。
Todo:
理想情况应该是根据 header 里的 Accept 选项来选择 response 的 content-type
没有记录 log,包括 error 的收集和用户访问 tracking。
Schema 没有严格的数据校验,应该加入一个 validater 的库
FetchAllTodo 先把返回形式改成我自己接受的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func FetchAllTodo(c *gin.Context) { var todos []Todo var _todos []interface{} db := Database() db.Find(&todos) if (len(todos) <= 0) { c.JSON(http.StatusNotFound, gin.H{"error" : "No todo found!"}) return } //transforms the todos for building a good response for _, todo := range todos { _todos = append(_todos, todo.Response()) } //c.JSON(http.StatusOK, _todos) c.XML(http.StatusOK, _todos) }
理论上这个接口应该承接搜索和分页的内容: api/v1/todos/?min_priority=2 api/v1/todos/?page=0&per_page=10 api/v1/todos/?min_priority=2&page=0&per_page=10 此时搜索一下 gorm 的 limit where offset 的用法,然后用 Query Param把参数传递到 gin。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 func FetchAllTodo(c *gin.Context) { var todos []Todo var _todos []interface{} db := Database() page_str := c.Query("page"); page, err := strconv.Atoi(page_str); if err!=nil { page = 0 } per_page_str := c.Query("per_page") per_page, err := strconv.Atoi(per_page_str); if err!=nil { per_page = 10 } db = db.Limit(per_page).Offset(per_page * page) min_priority_str := c.Query("min_priority") min_priority, err := strconv.Atoi(min_priority_str); if err==nil { db = db.Where("priority >= ?", min_priority) } db.Find(&todos) if (len(todos) <= 0) { c.JSON(http.StatusNotFound, gin.H{"error" : "No todo found!"}) return } //transforms the todos for building a good response for _, todo := range todos { _todos = append(_todos, todo.Response()) } c.JSON(http.StatusOK, _todos) //c.XML(http.StatusOK, _todos) }
Todo:
搜索这类功能最好引入 ElasticSearch,直接暴力叠参数和查关系数据库效率低,容易把库拖垮
分页器最好写成中间件,然后应用到所有的 list。
GET & PUT & PATCH SingleTodo 这个部分直接改动返回值,然后PUT & PATCH做出区别。 PUT 没有值是设置为默认值,PATCH没有值是设置为原来的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 func FetchSingleTodo(c *gin.Context) { var todo Todo todoId := c.Param("id") db := Database() db.First(&todo, todoId) if (todo.ID == 0) { c.JSON(http.StatusNotFound, gin.H{"error" : "No todo found!"}) return } c.JSON(http.StatusOK, todo.Response()) } func UpdateTodo(c *gin.Context) { var todo, todoTmp Todo todoId := c.Param("id") db := Database() db.First(&todoTmp, todoId) if (todoTmp.ID == 0) { c.JSON(http.StatusNotFound, gin.H{"message" : "No todo found!"}) }else if err := c.Bind(&todo); err == nil { fmt.Println(todoTmp) todo.ID = todoTmp.ID db.Save(&todo) c.JSON(http.StatusOK, todo.Response()) }else { c.JSON(http.StatusBadRequest, gin.H{"Bind Error" : err }) } } func PartialUpdateTodo(c *gin.Context) { var todo Todo todoId := c.Param("id") db := Database() db.First(&todo, todoId) if (todo.ID == 0) { c.JSON(http.StatusNotFound, gin.H{"message" : "No todo found!"}) }else if err := c.Bind(&todo); err == nil { //--- 可以手工一个个域更新,也可以一次性更新 todo --- //db.Model(&todo).Update("title", c.PostForm("title")) //db.Model(&todo).Update("completed", c.PostForm("completed")) db.Model(&todo).Update(todo) c.JSON(http.StatusOK, todo.Response()) }else { c.JSON(http.StatusBadRequest, gin.H{"Bind Error" : err }) } }
DeleteTodo 1 2 3 4 5 6 7 8 9 10 11 12 13 14 func DeleteTodo(c *gin.Context) { var todo Todo todoId := c.Param("id") db := Database() db.First(&todo, todoId) if (todo.ID == 0) { c.JSON(http.StatusNotFound, gin.H{ "error" : "No todo found!"}) return } db.Delete(&todo) c.JSON(http.StatusOK, gin.H{"message" : "Todo deleted successfully!"}) }
总结 至此,RESTFUL 的架子搭好了。完整的代码在github 说句实话,开发的速度大大低于我的预期,而且连英文的文档也支持非常不好,几乎只能直接读代码,我还是喜欢 rails 和 django 这种The Web framework for perfectionists with deadlines的框架。
todo:
缺少一些批量的接口,比如batch_POST1 2 v1.POST("/", BatchCreateTodo) v1.GET("/", BatchFetchTodo)
还没有实现 auth
对 header 的控制不够,缺少根据 header 对内容进行转变的中间件。
无埋点方案的 log 和 tracking
gorm 还没有使用一对多、多对多关系
Hypermedia,我希望搞成类似 django 处理模板 url 的 URL reverse技术
调研了一下,有两个需求做起来非常的困难。所以我接下来会去尝试其他的框架或者类库。
Go 的框架基本上不慢 。