Hãy đọc kỹ những thay đổi theo phiên bản
- Bổ xung kết nối WebSocket
- Các hàm bổ trợ cho go-pg
- Thêm hàm Delete, Put cho resto
- Thêm hàm dùng chung cho template
- Thêm chức năng bổ xung REST API để các dịch vụ khác truy vấn lấy thông tin: tên dịch vụ, thời gian kể từ lúc khởi động, danh sách các route + phần quyền...
- Session cần kéo dài expire date khi người dùng tiếp tục truy cập
- Bổ xung cách khác để gửi email, gom vào một dịch vụ chuyên biệt để gửi email
- Chức năng chạy schedule task để dọn dẹp ví dụ dọn thư mục log, xoá bớt orphan entry trong Redis
Module này tổng hợp nhiều package hữu dụng, sử dụng cùng với Iris framework để tạo ra một ứng dụng hoàn chỉnh
- config: cấu hình, sử dụng Viper config
- template: chuyên xử lý template engine
- session: quản lý session, kết nối vào redis. Phụ thuộc vào iris session
- resto: thư viện rest client hỗ trợ retry, sử dụng go-retryablehttp
- rbac: phân quyền Role Based Access Control
- pmodel: định nghĩa cấu trúc dữ liệu dùng chung giữa package rbac, session
- db: kết nối CSDL Postgresql
- email: gửi email theo nhiều cách khác nhau
- logger
go get -u github.com/TechMaster/core@main
Chú ý do module core luôn đi cùng với module iris, viper do đó bạn cần bổ xung
go get -u github.com/kataras/iris/v12@master
Để thử nghiệm nhanh các tính năng của module core bằng cách:
git clone https://github.com/TechMaster/core.git
cd core
go mod tidy
Khởi động một redis server. Trước đó hãy tạo thư mục data để map volume
docker run --name=redis -p 6379:6379 -d -e REDIS_PASSWORD=123 -v $PWD/data:/data redis:alpine3.14 /bin/sh -c 'redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}'
Chạy lệnh
go run main.go
Truy cập địa chỉ http://localhost:9001, login thử với các user khác nhau
package main
import (
"video/router"
"github.com/TechMaster/core/config"
"github.com/TechMaster/core/rbac"
"github.com/TechMaster/core/session"
"github.com/TechMaster/core/sessions"
"github.com/TechMaster/core/tmpl"
"github.com/TechMaster/core/logger"
"github.com/kataras/iris/v12"
"github.com/spf13/viper"
)
func main() {
app := iris.New()
config.ReadConfig()
logFile := logger.Init() //Cần phải có 2 file error.html và info.html ở /views
if logFile != nil {
defer logFile.Close()
}
redisDb := session.InitRedisSession()
defer redisDb.Close()
app.Use(session.Sess.Handler())
// Load các roles vào bộ nhớ
rbac.LoadRoles(func() []pmodel.Role {
return controller.Roles
})
rbacConfig := rbac.NewConfig()
rbacConfig.MakeUnassignedRoutePublic = true //mọi route không dùng rbac coi là public
rbac.Init(rbacConfig) //Khởi động với cấu hình mặc định
//đặt hàm này trên các hàm đăng ký route - controller
app.Use(rbac.CheckRoutePermission)
app.HandleDir("/", iris.Dir("./static")) //phục vụ thư mục file tĩnh
router.RegisterRoute(app) //Cấu hình đường dẫn đến các controller
template.InitViewEngine(app) //Khởi tạo View Template Engine
// Meger các route load từ database vào RBAC
rbac.LoadRules(func() []pmodel.Rule {
rules := make([]pmodel.Rule, 0)
for _, rule := range controller.RulesDb {
rules = append(rules, *rule)
}
return rules
})
//Luôn để hàm này sau tất cả lệnh cấu hình đường dẫn với RBAC
rbac.BuildPublicRoute(app)
//rbac.DebugRouteRole()
_ = app.Listen(viper.GetString("port"))
}
Cần đảm bảo phải có 2 file config.dev.json
và config.product.json
ở thư mục gốc của dự án
.
├── config.dev.json
├── config.product.json
Để đọc giá trị cấu hình dùng lệnh viper.GetString("key")
hoặc viper.GetInt("key")
_ = app.Listen(viper.GetString("port"))
Tham khảo file cấu hình config.dev.json
package config có hàm này để cho biết ứng dụng đang chạy mode Debug hay Production từ đó nạp file cấu hình cho phù hợp
/*
Trả về true nếu ứng dụng đang chạy ở chế độ Debug và ngược lại
*/
func IsAppInDebugMode() bool {
appCommand := os.Args[0]
if strings.Contains(appCommand, "debug") || strings.Contains(appCommand, "exe") {
return true
}
return false
}
Docker Secret là một tính năng trong Docker Swarm dùng để lưu thông tin nhạy cảm như password. Như vậy bạn sẽ không để lộ password trong file cấu hình hay file docker-compose.yml.
version: "3.8"
secrets:
pgpass: # Khai báo import pgpass từ bên ngoài
external: true
services:
whoami:
image: main
secrets:
- pgpass # Sử dụng trong dịch vụ này
Tiếp đến cấu hình file configure, đánh dấu key pgpass
bởi string @@pgpass
. **Nhớ phải có @@
trước tên secret key **để package config khi đọc sẽ tìm đến file ở thư mục /run/secrets đọc nội dung thực của secret
{
"database": {
"user": "root",
"pgpass": "@@pgpass",
Chú ý: nếu đã dùng module github.com/TechMaster/core thì buộc phải dùng cả github.com/TechMaster/core/logger. Tuyệt đối không dùng github.com/TechMaster/logger !
Khởi tạo mặc định, bạn cần tạo một thư mục logs để logger ghi vào file log. Trong thư mục views cần phải có file views/error.html để hiển thị lỗi và views/info.html để hiển thị thông tin ra trình duyệt kiểu server side rendering
logFile := logger.Init() //Cần phải có 2 file error.html và info.html ở /views
if logFile != nil {
defer logFile.Close()
}
Khởi tạo có cấu hình riêng. Nếu stack trace lỗi quá dài dòng, bạn có thể đặt LogConfig.Top
giảm xuống 2 hoặc 3
logFile := logger.Init(LogConfig{
LogFolder: "mylog/", // thư mục chứa log file. Nếu rỗng có nghĩa là không ghi log ra file
ErrorTemplate: "error", // tên view template sẽ render error page
InfoTemplate: "info", // tên view template sẽ render info page
Top: 4, // số dòng đầu tiên trong stack trace sẽ được giữ lại
}
)
if logFile != nil {
defer logFile.Close()
}
func Log(ctx iris.Context, err error) //Sử dụng trong logic xử lý HTTP request đến Iris framework
func Log2(err error) //Log lỗi mà không báo về cho HTTP client
func Info(ctx iris.Context, msg string, redirectLink ...string) //Thông báo
- Những lỗi do người dùng (HTTP client) tạo ra không ảnh hưởng gì đến ứng dụng ví dụ nhập sai dữ liệu... chỉ tạo
logger.Log(ctx, eris.Warning("Email không hợp lệ").BadRequest()
logger.Log(ctx, eris.NewFromMsg(err, "Không đọc được dữ liệu gửi lên").SetType(eris.WARNING).BadRequest())
Việc đặt cấp độ lỗi cao chỉ làm rác console log hoặc log file của ứng dụng, không giải quyết được vấn đề gì cả
- Những lỗi thực sự do ứng dụng gây ra thì đặt ở cấp độ
ERROR
,SYSERROR
,PANIC
. Trước khi gọi lệnh panic hãy cố gắng log lỗi ra file
logger.Log2(eris.NewFromMsg(err, "Lỗi đặc biệt nghiêm trọng").SetType(eris.PANIC)) //log ra file
panic(err) //rồi cho hệ thống sập !
- Cần đặt đúng loại lỗi. Việc này giúp client xử lý lỗi tốt hơn như:
eris.Warning().BadRequest() //Yêu cầu gửi lên bị lỗi
eris.Warning().UnAuthorized() //Người dùng không đủ quyền để thực thi
eris.Warning().NotFound() //Không tìm thấy bản ghi hay tài nguyên trên server
eris.Warning().InternalServerError() //Dành cho hầu hết lỗi phát sinh phía server
Nếu bạn viết ứng dụng đơn lẻ thì có thể lưu trực tiếp session vào vùng nhớ của ứng dụng web. Khi này bạn không cần dùng Redis hay bất kỳ CSDL.
Hàm khởi tạo Session trong file main.go sẽ như sau
app.Use(session.Sess.Handler())
Khi có nhiều ứng dụng web, microservice dùng chung một domain nhưng định địa chỉ bằng các sub domain khác nhau, để có được chức năng Single Sign On (đăng nhập một lần, nhưng truy cập được nhiều site cùng chung domain), chúng ta buộc phải lưu session ra database chung ví dụ như Redis.
redisDb := session.InitRedisSession()
defer redisDb.Close()
app.Use(session.Sess.Handler())
package session cung cấp 2 hàm
func GetAuthInfo(ctx iris.Context) (authinfo *pmodel.AuthenInfo)
func GetAuthInfoSession(ctx iris.Context) (authinfo *pmodel.AuthenInfo)
GetAuthInfo
đầu tiên sẽ lấy thông tin đăng nhập của người dùng từ ViewData["authinfo"]
nếu không thấy sẽ tiếp tục gọi vào GetAuthInfoSession
để lấy thông tin từ session.
Nếu trả về nil
có nghĩa người dùng chưa đăng nhập. Nếu khác nil
thì cấu trúc dữ liệu trả về như sau:
type AuthenInfo struct {
Id string //unique id của user
FullName string //họ và tên đầy đủ của user
Email string //email cũng phải unique
Avatar string //unique id hoặc tên file ảnh đại diện
Roles Roles //kiểu map[int]bool. Cần phải chuyển đổi Roles []int32 `pg:",array"` sang
}
Tôi đã bỏ hàm func IsLogin(ctx iris.Context)
vì hàm này không trả về đầy đủ được thông tin. Ngược lại hàm func GetAuthInfo
trả về được id, full name, email, avatar và danh sách roles của người dùng.
Bạn cần lấy danh sách roles của người dùng mảng các chuỗi mô tả role. Hãy truyền authinfo.Roles
vào hàm này
rbac.RolesNames(roles pmodel.Roles)[]string
Bạn cần in ra danh sách role vừa có giá trị int và có chuỗi mô tả để debug cho thuận tiện 3:trainer, 8:maintainer
. Hãy tham khảo hàm func GetAll
rolesString := ""
for i, role := range user.Roles {
rolesString += fmt.Sprintf("%d:%s", role, rbac.RoleName(role))
if i < len(user.Roles)-1 {
rolesString += ", "
}
}
Bạn có một mảng []int
thể hiện các role, cần chuyển sang kiểu type Roles map[int]interface{}
. Hãy dùng IntArrToRoles
func IntArrToRoles(intArr []int) Roles
Hàm này thực hiện việc xoá session id và phần tử session trong tập user.Id. Nó có tác dụng loại bỏ bớt rác trong redis database.
func Logout(ctx iris.Context) error
Khi Admin thay đổi role người dùng. Người này không cần logout mà role có tác dụng ngay, trên mọi thiết bị anh ta đang đăng nhập.
func UpdateRole(userID string, roles []int) error
Xem chi tiết controller/changerole.go và session/update_role.go
Để thực hiện được tính năng này phải lưu quan hệ một user.Id chứa một tập các Session.id. Khi cập nhật Roles cho một user.Id chúng ta nhanh chóng tìm được tất cả các Session của user đó để cập nhật. Ngoài ra phải đặt Expire time để xoá bản ghi này.
Nếu vì một nguyên nhân nào đó, thuật toán đồng bộ Role của user trên mọi thiết bị bị lỗi. User có thể logout rồi login lại. Thuật toán này chưa hoàn hảo, nó có thể để lại rác trong Redis trong một số trường hợp.
Hiện nay tôi copy toàn bộ package https://github.com/kataras/iris/tree/master/sessions vào thư mục sessions. Hiện chưa sửa đổi gì. Tuy nhiên sẽ fix bug ngay nếu package này có lỗi.
Trong framework Iris, khi người dùng logout ở một trình duyệt trên một thiết bị, không làm sao xoá được key = sessionID. Hàm này không những xoá key = sessionID mà còn sửa lại entry UserID bỏ bớt phần tử sessionID
func Logout(ctx iris.Context) error
Cần khởi tạo và cấu hình RBAC trong file main.go Sau đó trong router viết hàm đăng ký route + roleExp + isPrivate + controller RBAC hỗ trợ 5 hàm:
Allow(rbac.RoleX, rbac.RoleY)
: cho phép RoleX và RoleYAllowAll()
: cho phép tất cả các roleForbid(rbac.RoleA, rbac.RoleB)
: cấm role RoleA, RoleB, các role khác đều được phépForbidAll()
: cấm tất cả các role trừ admin
Có thể chuyển app và hoặc đối tượng party vào tham số đầu tiên của rbac
func RegisterRoute(app *iris.Application) {
// Tất cả route phải được viết qua rbac để kiểm soát
// Nếu không viết vào rbac nó sẽ là public cho tất cả được truy cập và sẽ không dynamic
rbac.Get(app, "/", rbac.AllowAll(), false, controller.ShowHomePage)
rbac.Post("/login", rbac.AllowAll(), false, controller.Login)
rbac.Get(app, "/logout", rbac.AllowAll(), true,controller.LogoutFromWeb)
blog := app.Party("/blog")
{
rbac.Get(blog, "/", rbac.AllowAll(), false, controller.GetAllPosts)
rbac.Get(blog, "/all", rbac.AllowAll(), true controller.GetAllPosts)
rbac.Get(blog, "/create", rbac.ForbidAll(), true, controller.GetAllPosts)
rbac.Get(blog, "/{id:int}", rbac.ForbidAll(), true,controller.GetPostByID)
rbac.Get(blog, "/delete/{id:int}", rbac.ForbidAll(), true, controller.DeletePostByID)
rbac.Any(blog, "/any", rbac.ForbidAll(), true, controller.PostMiddleware)
}
student := app.Party("/student")
{
rbac.Get(student, "/submithomework", rbac.ForbidAll(), true, controller.SubmitHomework)
}
trainer := app.Party("/trainer")
{
rbac.Get(trainer, "/createlesson", rbac.ForbidAll(), true, controller.CreateLesson)
}
sysop := app.Party("/sysop")
{
rbac.Get(sysop, "/backupdb", rbac.ForbidAll(), true, controller.BackupDB)
rbac.Get(sysop, "/upload", rbac.ForbidAll(), true, controller.ShowUploadForm)
rbac.Post(sysop, "/upload", rbac.ForbidAll(), true, iris.LimitRequestBodySize(300000), controller.UploadPhoto)
}
}
Sau đó sẽ gọi rbac.LoadRules
để load tất cả các rules đã được phân quyền từ database
rbac.LoadRules(func() []pmodel.Rule {
// Viết SQL lấy các rules từ database về
return rules
})
Cuối cùng gọi hàm BuildPublicRoute
để phân các route public
//Luôn để hàm này sau tất cả lệnh cấu hình đường dẫn với RBAC
rbac.BuildPublicRoute(app)
pmodel là nơi định nghĩa cấu trúc dữ liệu phụ vụ việc đăng nhập, quản lý người dùng
Cấu trúc chi tiết User xem ở đây pmodel/user.go
Danh sách các Role cấp cho một user
type Roles map[int]interface{}
Struct sẽ lưu trong session để hệ thống quản lý phiên đăng nhập của người dùng
type AuthenInfo struct {
Id string //unique id của user
FullName string //họ và tên đầy đủ của user
Email string //email cũng phải unique
Avatar string //unique id hoặc tên file ảnh đại diện
Roles Roles //kiểu map[int]bool. Cần phải chuyển đổi Roles []int32 `pg:",array"` sang
}
Chú ý kiểu map[int]bool
khi lưu vào Redis sẽ biến thành map[string]bool
Chuyển đổi Roles kiểu map[int] bool sang mảng []int để lưu xuống CSDL
func RolesToIntArr(roles Roles) []int
Chuyển đổi kiểu intArray trong đó mỗi phần tử ứng với một role, sang kiểu map[int] bool
func IntArrToRoles(intArr []int) Roles
Cấu trúc chi tiết Rule xem ở đây pmodel/rule.go
Danh sách các rules sẽ được lấy từ database
/*
Rule là cấu hình cho việc kiểm tra quyền truy cập
- Nếu IsPrivate = true thì cần kiểm tra quyền và Roles, AccessType không có ý nghĩa, có thể để AccessType = "allow_all" cho dễ hiểu
*/
type Rule struct {
ID int //ID của rule
Name string //Tên của rule
Roles []int `pg:",array"` //Danh sách các role có thể truy xuất
AccessType string //Allow, AllowAll, ForbidAll, Forbid, ForbidAll
Method string //GET, POST, PUT, DELETE, PATCH
Path string //Đường dẫn
IsPrivate bool //true: cần kiểm tra quyền, false: không cần kiểm tra quyền
Services string //Dịnh nghĩa các rule cho các service khác nhau
}
Trong template có 2 hàm khởi tạo View Template Engine.
viewFolder
: thư mục chứa view template. Mặc định nên để là viewsdefaultLayout
: tên file template layout mặc định. Mặc định nó phải nằm trong thư mụclayouts
bên trong thư mục chứa view template.
//Nếu bạn dùng HTML template
func InitHTMLEngine(app *iris.Application, viewFolder string, defaultLayout string)
//Nếu bạn dùng Block template
func InitBlockEngine(app *iris.Application, viewFolder string, defaultLayout string)
Tốt nhất bạn nên tuân thủ cấu trúc thư mục như sau
views
├── layouts
│ └── default.html -> layout mặc định
├── partials -> template component hiển thị một phần hay dùng lại của trang web
│ ├── footer.html
│ ├── header.html
│ └── menu.html
├── changerole.html
├── error.html -> trang hiển thị lỗi. logger.Log cần phải có
├── index.html
└── info.html -> trang hiển thị thông báo. logger.Log cần phải có
Tôi đã copy code ở https://github.com/kataras/blocks vào thư mục blocks Sửa lại lỗi không đặt được layout mặc định
Trong hàm main.go cấu hình view template engine như sau
template.InitBlockEngine(app, "./views", "default")
Hỏi: Làm sao để bổ xung custom function vào view template engine?
Đáp: Hãy gọi biến global ứng với template engine bạn chọn. Ví dụ bạn dùng BlockEngine
template.BlockEngine.AddFunc("listmenu", func() []string {
return []string{"home", "products", "about"}
})
response, err := resto.Retry(numberOfTimesToTry, numberOfMilliSecondsToWait).Post(url, jsondata)
response, err := resto.Retry(numberOfTimesToTry, numberOfMilliSecondsToWait).Get(url)
Ví dụ chi tiết
response, err := resto.Retry(5, 1000).Post("http://auth/api/login", loginReq)
if err != nil {
logger.Log(ctx, eris.NewFromMsg(err, "Lỗi khi gọi Auth service").InternalServerError())
return
}
if response.StatusCode != iris.StatusOK {
var res struct {
Error string `json:"error"`
}
_ = json.NewDecoder(response.Body).Decode(&res)
logger.Log(ctx, eris.Warning(res.Error).UnAuthorized())
return
}
db.ConnectPostgresqlDB(config.Config) //Kết nối vào CSDL
defer db.DB.Close()
Cấu hình kết nối CSDL để ở trong file config.dev.json
và config.product.json
{
"database": {
"user": "postgres",
"password": "123",
"database": "iris",
"address": "localhost:5432"
},
}
Đầu tiên là interface gửi email trong mail_sender.go
type MailSender interface {
SendPlainEmail(to []string, subject string, body string) error
SendHTMLEmail(to []string, subject string, tmplFile string, data map[string]interface{}) error
}
gmail_smtp.go gửi email sử dụng một tài khoản Gmail phải bật chế độ không an toàn mới gửi được. Còn cấu hình bằng OAuth2 Gmail Service thì quá khó. Tôi bó tay.
fake_gmail.go cũng gửi đi từ một tài khoản Gmail, nhưng địa chỉ thư nhận luôn là một hòm thư cấu hình sẵn test_receive_email string
dùng để kiểm tra, debug ứng dụng.
func InitFakeGmail(config *SMTPConfig, test_receive_email string)
email_db.go thay vì gửi email thì tạo một records trong bảng debug.emailstore
CSDL Postgresql. Cấu trúc bảng như dưới.
type EmailStore struct {
tableName struct{} `pg:"debug.emailstore"`
Id int `pg:",pk"`
Receipient string
Subject string
Body string
CreatedAt time.Time
}
Phiên bản 0.1.28 bổ xung thêm gửi email qua Asynq và Redis Stream Xem redis_mail.go
Tuyệt đối không được lưu secret key hay các chuỗi nhạy cảm vào đây. Xem chi tiết pass/password.go Tạo ra một interface chung cho tất cả các thư viện băm password tuân thủ
type PasswordLib interface {
Hash(password string) (hash string)
Compare(password string, hashpass string) bool
}
Hiện tại dùng thư viện Argon2id là thư viện password tốt nhất hiện nay
func HashPassword(inputpass string) string {
return PassLib.Hash(inputpass)
}
/*
Hàm check password hỗ trợ cả kiểu SHA1 cũ và bcrypt mới
- inputpass: password nhập vào lúc login
- hashedpass: password đã băm đọc từ CSDL
- salt: chuỗi nhiễu tạo ra từ thuật toán SHA1 cũ
*/
func CheckPassword(inputpass string, hashedpass string, salt string) bool {
if salt != "" {
pass := p.NewPassword(sha1.New, 50, 64, 10000)
return pass.VerifyPassword(inputpass, hashedpass, salt) //Sửa theo yêu cầu Nhật Đức
} else {
return PassLib.Compare(inputpass, hashedpass)
}
}
Sử dụng github.com/didip/tollbooth/v6
. Tolbooth là middleware để giới hạn số lượng http request đến trong một khoảng thời gian. Cấu hình trong router như sau:
import (
"github.com/TechMaster/core/ratelimit"
"github.com/didip/tollbooth/v6"
)
func RegisterRoute(app *iris.Application) {
limiter := tollbooth.NewLimiter(1, nil) //Tối đa 1 request trong 1 giây
app.Post("/login", ratelimit.LimitHandler(limiter), controller.Login) //áp dụng với POST /login
}
Thay v0.1.3 bằng phiên bản thực tế
git add .
git commit -m "v0.1.10"
git tag v0.1.10
git push origin v0.1.10
GOPROXY=proxy.golang.org go list -m github.com/TechMaster/core@v0.1.10