在本教程中,我们将学习如何使用Go语言开发和部署安全的REST API。
为什么选择Go
Go是一个非常有趣的编程语言,是一种强类型语言,编译非常快,它的性能和c++差不多,Go有goroutine——比线程更高效,并且提供自由的web静态类型——我理解这不是新的功能,但我喜欢go的实现方式。
将创建什么项目呢?
我们将构建一个联系人/电话簿管理应用程序,我们的API将允许用户将联系人添加到他们的个人资料中,他们将能够在他们的手机丢失的情况下找回联系人。
准备工作
假设您已经安装了以下软件包:
- Go
- Postgresql
- Goland IDE
什么是REST?
REST是Representational State Transfer(表述性状态转移)的简称,它是现代客户端应用程序通过http与数据库和服务器通信所使用的机制。因此,如果你有一个新的创业想法,或者你想创建一个很棒的副业项目。REST协议是最好的选择。
创建项目
我们从项目需要使用的go包开始,幸运的是Go标准库已经功能很丰富,无需第三方库就可以实现一个完整的web服务,可以查看net.http包。为了开发的简单,我们将使用以下go包:
- gorilla/mux:一个强大的URL路由器和分配器。我们使用这个包来匹配URL路由及其处理程序。
- jinzhu/gorm:Go的orm库,对开发人员非常友好。使用这个orm库可以顺畅的和数据库交互。
- dgrijalva/jwt-go:用于JWT令牌的签名和验证
- joho/godotenv:用于将.env文件加载到项目中
要安装这些包,请打开终端并运行:
go get github.com/{package-name}
这个命令将把软件包安装到您Go环境中。
项目结构:
utils.go
package utils
import (
"encoding/json"
"net/http"
)
func Message(status bool, message string) (map[string]interface{}) {
return map[string]interface{} {"status" : status, "message" : message}
}
func Respond(w http.ResponseWriter, data map[string] interface{}) {
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
utils.go包含方便的utils函数来构建json消息并返回json响应。在我们继续之前,请注意两个函数Message()和response()。
关于JWT
JWT是一种开放的行业标准RFC 7519方法,用于客户端和服务端安全交互。通过会话很容易识别web应用程序用户,但是,当你的web应用程序API与Android或IOS客户端交互时,由于http请求的无状态特性,会话变得不可用。使用JWT,我们可以为每个经过身份验证的用户创建一个唯一的令牌,这个令牌将包含在向API服务器发出的后续请求头中,这个方法能识别调用我们API的每个用户。看下实现方式:
package app
import (
"net/http"
u "lens/utils"
"strings"
"go-contacts/models"
jwt "github.com/dgrijalva/jwt-go"
"os"
"context"
"fmt"
)
var JwtAuthentication = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notAuth := []string{"/api/user/new", "/api/user/login"} //不需要认证的请求路径列表
requestPath := r.URL.Path //当前请求路径
//检查是否需要认证,如果不需要直接响应请求
for _, value := range notAuth {
if value == requestPath {
next.ServeHTTP(w, r)
return
}
}
response := make(map[string] interface{})
tokenHeader := r.Header.Get("Authorization") //从请求头获取token
if tokenHeader == "" { //令牌不存在,返回错误代码403未授权
response = u.Message(false, "Missing auth token")
w.WriteHeader(http.StatusForbidden)
w.Header().Add("Content-Type", "application/json")
u.Respond(w, response)
return
}
splitted := strings.Split(tokenHeader, " ") //令牌通常以`Bearer {token-body}`格式出现, 检查检索到的令牌是否符合要求
if len(splitted) != 2 {
response = u.Message(false, "Invalid/Malformed auth token")
w.WriteHeader(http.StatusForbidden)
w.Header().Add("Content-Type", "application/json")
u.Respond(w, response)
return
}
tokenPart := splitted[1] //获取token有用部分
tk := &models.Token{}
token, err := jwt.ParseWithClaims(tokenPart, tk, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("token_password")), nil
})
if err != nil { //格式错误的令牌,返回http代码403
response = u.Message(false, "Malformed authentication token")
w.WriteHeader(http.StatusForbidden)
w.Header().Add("Content-Type", "application/json")
u.Respond(w, response)
return
}
if !token.Valid { //令牌无效,可能未在此服务器上签名
response = u.Message(false, "Token is not valid.")
w.WriteHeader(http.StatusForbidden)
w.Header().Add("Content-Type", "application/json")
u.Respond(w, response)
return
}
//一切顺利,继续请求并将调用者设置为从已解析的令牌检索到的用户
fmt.Sprintf("User %", tk.Username) //Useful for monitoring
ctx := context.WithValue(r.Context(), "user", tk.UserId)
r = r.WithContext(ctx)
next.ServeHTTP(w, r) //proceed in the middleware chain!
});
}
通过注释可以清楚的理解上面的代码。创建一个中间件来解析请求,检查当前认证token,检验token是否有效。如果token认证失败,发送错误信息给客户端。验证成功就继续对请求进行处理,接下来会看到如何根据API请求拿到用户信息。
创建用户注册和登录系统
我们希望用户在备份或存储联系方式之前,能够注册和登录系统。我们需要做的第一件事是连接到数据库,我们使用.env文件来存储数据库证书,.env内容如下:
db_name = gocontacts
db_pass = **** //当前用户密码
db_user = postgres
db_type = postgres
db_host = localhost
db_port = 5434
token_password = thisIsTheJwtSecretPassword //不要提交到git
然后,我们可以使用以下代码片段连接到数据库:
package models
import (
_ "github.com/jinzhu/gorm/dialects/postgres"
"github.com/jinzhu/gorm"
"os"
"github.com/joho/godotenv"
"fmt"
)
var db *gorm.DB //database
func init() {
e := godotenv.Load() //Load .env file
if e != nil {
fmt.Print(e)
}
username := os.Getenv("db_user")
password := os.Getenv("db_pass")
dbName := os.Getenv("db_name")
dbHost := os.Getenv("db_host")
dbUri := fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable password=%s", dbHost, username, dbName, password) //Build connection string
fmt.Println(dbUri)
conn, err := gorm.Open("postgres", dbUri)
if err != nil {
fmt.Print(err)
}
db = conn
db.Debug().AutoMigrate(&Account{}, &Contact{}) //Database migration
}
//returns a handle to the DB object
func GetDB() *gorm.DB {
return db
}
以上代码做了很简单的事情,init函数会在包引入后调用,根据.env文件拿到连接数据库的信息,然后创建一个连接字符串创建数据库连接。
创建应用程序访问端点
到目前为止,我们已经能够创建JWT中间件并连接到我们的数据库。接下来是创建应用程序的入口点,请参阅下面的代码片段:
package main
import (
"github.com/gorilla/mux"
"go-contacts/app"
"os"
"fmt"
"net/http"
)
func main() {
router := mux.NewRouter()
router.Use(app.JwtAuthentication) //attach JWT auth middleware
port := os.Getenv("PORT") //Get port from .env file, we did not specify any port so this should return an empty string when tested locally
if port == "" {
port = "8000" //localhost
}
fmt.Println(port)
err := http.ListenAndServe(":" + port, router) //Launch the app, visit localhost:8000/api
if err != nil {
fmt.Print(err)
}
}
创建了一个Router对象,使用Use()函数将JWT认证中间件附加到路由器上,然后监听请求。
下面使用位于func main()左边的run按钮来编译和启动应用程序,如果一切正常,你应该在控制台中看不到错误,如果有错误,再看看你的数据库连接参数,看看它们是否相关。
创建并认证用户
创建models/account.go文件:
package models
import (
"github.com/dgrijalva/jwt-go"
u "lens/utils"
"strings"
"github.com/jinzhu/gorm"
"os"
"golang.org/x/crypto/bcrypt"
)
/*
JWT claims struct
*/
type Token struct {
UserId uint
jwt.StandardClaims
}
//表示用户账号的结构体
type Account struct {
gorm.Model
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token";sql:"-"`
}
//校验用户信息
func (account *Account) Validate() (map[string] interface{}, bool) {
if !strings.Contains(account.Email, "@") {
return u.Message(false, "Email address is required"), false
}
if len(account.Password) < 6 {
return u.Message(false, "Password is required"), false
}
//Email必须唯一
temp := &Account{}
//检查错误和重复的电子邮件
err := GetDB().Table("accounts").Where("email = ?", account.Email).First(temp).Error
if err != nil && err != gorm.ErrRecordNotFound {
return u.Message(false, "Connection error. Please retry"), false
}
if temp.Email != "" {
return u.Message(false, "Email address already in use by another user."), false
}
return u.Message(false, "Requirement passed"), true
}
func (account *Account) Create() (map[string] interface{}) {
if resp, ok := account.Validate(); !ok {
return resp
}
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
account.Password = string(hashedPassword)
GetDB().Create(account)
if account.ID <= 0 {
return u.Message(false, "Failed to create account, connection error.")
}
//为新注册的账号创建新的JWT token
tk := &Token{UserId: account.ID}
token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk)
tokenString, _ := token.SignedString([]byte(os.Getenv("token_password")))
account.Token = tokenString
account.Password = "" //删除密码
response := u.Message(true, "Account has been created")
response["account"] = account
return response
}
func Login(email, password string) (map[string]interface{}) {
account := &Account{}
err := GetDB().Table("accounts").Where("email = ?", email).First(account).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return u.Message(false, "Email address not found")
}
return u.Message(false, "Connection error. Please retry")
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { //密码不匹配
return u.Message(false, "Invalid login credentials. Please try again")
}
//登录成功
account.Password = ""
//创建 JWT token
tk := &Token{UserId: account.ID}
token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk)
tokenString, _ := token.SignedString([]byte(os.Getenv("token_password")))
account.Token = tokenString //在响应中存放token
resp := u.Message(true, "Logged In")
resp["account"] = account
return resp
}
func GetUser(u uint) *Account {
acc := &Account{}
GetDB().Table("accounts").Where("id = ?", u).First(acc)
if acc.Email == "" { //用户没找到
return nil
}
acc.Password = ""
return acc
}
account.go实现了一些小功能,我们分解下。第一部分创建了两个结构体Token和Account代表JWT的token声明和用户账号声明。Validate函数检验客户端发送来的数据,Create()函数创建新账号并生成需要返回给客户端的JWT token。Login(username, password)认证已经存在的用户,然后如果认证成功生成JWT token。
authController.go
package controllers
import (
"net/http"
u "go-contacts/utils"
"go-contacts/models"
"encoding/json"
)
var CreateAccount = func(w http.ResponseWriter, r *http.Request) {
account := &models.Account{}
err := json.NewDecoder(r.Body).Decode(account) //decode the request body into struct and failed if any error occur
if err != nil {
u.Respond(w, u.Message(false, "Invalid request"))
return
}
resp := account.Create() //Create account
u.Respond(w, resp)
}
var Authenticate = func(w http.ResponseWriter, r *http.Request) {
account := &models.Account{}
err := json.NewDecoder(r.Body).Decode(account) //decode the request body into struct and failed if any error occur
if err != nil {
u.Respond(w, u.Message(false, "Invalid request"))
return
}
resp := models.Login(account.Email, account.Password)
u.Respond(w, resp)
}
代码很简单。包含/user/new和/user/login请求路径的处理函数。将以下代码添加到main函数,注册路由:
router.HandleFunc("/api/user/new", controllers.CreateAccount).Methods("POST")
router.HandleFunc("/api/user/login", controllers.Authenticate).Methods("POST")
上面的代码注册/user/new和/user/login端点并传递它们相应的请求处理程序。
现在,重新编译代码并使用postman访问localhost:8000/api/user/new,将请求体设置为application/json,如下所示:
如果您尝试用相同的请求体调用/user/new两次,根据我们的功能实现,您将收到电子邮件已经存在的响应。
创建联系人
应用程序的部分功能是让用户创建/存储联系人。联系人将有name和phone,我们将这些定义为struct的属性。下面的代码片段属于models/contact.go
package models
import (
u "go-contacts/utils"
"github.com/jinzhu/gorm"
"fmt"
)
type Contact struct {
gorm.Model
Name string `json:"name"`
Phone string `json:"phone"`
UserId uint `json:"user_id"` //该联系人所属的用户
}
/*
检验请求参数是否合法,如果合法返回true
*/
func (contact *Contact) Validate() (map[string] interface{}, bool) {
if contact.Name == "" {
return u.Message(false, "Contact name should be on the payload"), false
}
if contact.Phone == "" {
return u.Message(false, "Phone number should be on the payload"), false
}
if contact.UserId <= 0 {
return u.Message(false, "User is not recognized"), false
}
//所有需要的参数都已经存在
return u.Message(true, "success"), true
}
func (contact *Contact) Create() (map[string] interface{}) {
if resp, ok := contact.Validate(); !ok {
return resp
}
GetDB().Create(contact)
resp := u.Message(true, "success")
resp["contact"] = contact
return resp
}
func GetContact(id uint) (*Contact) {
contact := &Contact{}
err := GetDB().Table("contacts").Where("id = ?", id).First(contact).Error
if err != nil {
return nil
}
return contact
}
func GetContacts(user uint) ([]*Contact) {
contacts := make([]*Contact, 0)
err := GetDB().Table("contacts").Where("user_id = ?", user).Find(&contacts).Error
if err != nil {
fmt.Println(err)
return nil
}
return contacts
}
与models/Account.go相同。我们创建一个函数Validate()来验证传入的参数,如果发生任何异常,则返回一个错误信息,然后编写函数create()来将这个联系人插入数据库。
剩下的就是检索联系人了。
router.HandleFunc("/api/me/contacts", controllers.GetContactsFor).Methods("GET")
将上面的代码片段添加到main函数。去告诉路由器注册/me/contacts路径。创建controllers.GetContactsFor处理程序处理API请求。
contactsController.go
package controllers
import (
"net/http"
"go-contacts/models"
"encoding/json"
u "go-contacts/utils"
"strconv"
"github.com/gorilla/mux"
"fmt"
)
var CreateContact = func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user") . (uint) //获取发送请求的用户的id
contact := &models.Contact{}
err := json.NewDecoder(r.Body).Decode(contact)
if err != nil {
u.Respond(w, u.Message(false, "Error while decoding request body"))
return
}
contact.UserId = user
resp := contact.Create()
u.Respond(w, resp)
}
var GetContactsFor = func(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id, err := strconv.Atoi(params["id"])
if err != nil {
//传递的路径参数不是整数
u.Respond(w, u.Message(false, "There was an error in your request"))
return
}
data := models.GetContacts(uint(id))
resp := u.Message(true, "success")
resp["data"] = data
u.Respond(w, resp)
}
和authController.go功能类似,但是它读取json请求内容并解码到Contract结构体中,如果有错误就立刻返回,没有就将联系人信息插入数据库。
获取属于用户的联系人
现在用户可以存储联系人了,万一手机丢失,想查询已经存储的联系人怎么办呢?访问/me/contacts会返回json格式的联系人信息。正常情况下查询用户联系人路径应该是:/user/{userId}/contacts这样的,指定userId作为路径参数是很危险的,因为每个认证过的用户都可以发起这样的请求,将获取到别的用户联系人信息。这可能会导致黑客攻击,这里我将指出JWT到作用。
我们可以使用r.Context().Value("user")很容易的拿到API调用者的id,记住我们将这个id设置到auth.go:我们的认证中间件。
package controllers
import (
"net/http"
"go-contacts/models"
"encoding/json"
u "go-contacts/utils"
"strconv"
"github.com/gorilla/mux"
"fmt"
)
var CreateContact = func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user") . (uint) //获取发送请求的用户的id
contact := &models.Contact{}
err := json.NewDecoder(r.Body).Decode(contact)
if err != nil {
u.Respond(w, u.Message(false, "Error while decoding request body"))
return
}
contact.UserId = user
resp := contact.Create()
u.Respond(w, resp)
}
var GetContactsFor = func(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
id, err := strconv.Atoi(params["id"])
if err != nil {
//传递的路径参数不是整数
u.Respond(w, u.Message(false, "There was an error in your request"))
return
}
data := models.GetContacts(uint(id))
resp := u.Message(true, "success")
resp["data"] = data
u.Respond(w, resp)
}
这个项目的代码在github - https://github.com/adigunhammedolalekan/go-contacts上。