转载自:https://dev.to/stevensunflash/using-domain-driven-design-ddd-in-golang-3ee5Using Domain-Driven Design(DDD)in Golang
#go#ddd#redis#postgres
Domain-Driven Design pattern is the talk of the town today.
Domain-Driven Design(DDD) is an approach to software development that simplifies the complexity developers face by connecting the implementation to an evolving model.
Note
This is not an article that explains the "ideal" way to implement DDD in Golang because the author is no way an expert on it. This article is rather the author's understanding of DDD based on his research. The author
will be very grateful to contributions on how to improve this article.
Check the github repo for the updated code:
https://github.com/victorsteven/food-app-server
Why DDD?
The following are the reasons to consider using DDD:
- Provide principles & patterns to solve difficult problems
- Base complex designs on a model of the domain
- Initiate a creative collaboration between technical and domain experts to iteratively refine a conceptual model that addresses domain problems.
The idea of Domain-Driven Design was inverted by Eric Evans. He wrote about it in a book which you can find some of the highlights here
DDD comprises of 4 Layers:
- Domain: This is where the domain and business logic of the application is defined.
- Infrastructure: This layer consists of everything that exists independently of our application: external libraries, database engines, and so on.
- Application: This layer serves as a passage between the domain and the interface layer. The sends the requests from the interface layer to the domain layer, which processes it and returns a response.
- Interface: This layer holds everything that interacts with other systems, such as web services, RMI interfaces or web applications, and batch processing frontends.
To have a thorough definition of terms of each layer, please refer to this
Let's get started.
We are going to build a food recommendation API.
You can get the code if you don't have all the time to read.
Get the API code here.
Get the Frontend code here.
The very first thing to do is to initialize the dependency management. We will be using go.mod. From the root directory(path: food-app/), initialize go.mod:
go mod init food-app
This is how the project is going to be organized:
In this application, we will use postgres and redis databases to persist data. We will define a .env file that has connection information.
The .env file looks like this:
#Postgres | |
APP_ENV=local | |
API_PORT=8888 | |
DB_HOST=127.0.0.1 | |
DB_DRIVER=postgres | |
ACCESS_SECRET=98hbun98h | |
REFRESH_SECRET=786dfdbjhsb | |
DB_USER=steven | |
DB_PASSWORD=password | |
DB_NAME=food-app | |
DB_PORT=5432 | |
#Mysql | |
#DB_HOST=127.0.0.1 | |
#DB_DRIVER=mysql | |
#DB_USER=steven | |
#DB_PASSWORD=here | |
#DB_NAME=food-app | |
#DB_PORT=3306 | |
#Postgres Test DB | |
TEST_DB_DRIVER=postgres | |
TEST_DB_HOST=127.0.0.1 | |
TEST_DB_PASSWORD=password | |
TEST_DB_USER=steven | |
TEST_DB_NAME=food-app-test | |
TEST_DB_PORT=5432 | |
#Redis | |
REDIS_HOST=127.0.0.1 | |
REDIS_PORT=6379 | |
REDIS_PASSWORD= | |
This file should be located in the root directory(path: food-app/)
Domain Layer
We will consider the domain first.
The domain has several patterns. Some of which are:
Entity, Value, Repository, Service, and so on.
Since the application we are building here is a simple one, we consider just two domain patterns: entity and repository.
Entity
This is where we define the "Schema" of things.
For example, we can define a user's struct. See the entity as the blueprint to the domain.
package entity | |
import ( | |
"food-app/infrastructure/security" | |
"github.com/badoux/checkmail" | |
"html" | |
"strings" | |
"time" | |
) | |
type User struct { | |
ID uint64 `gorm:"primary_key;auto_increment" json:"id"` | |
FirstName string `gorm:"size:100;not null;" json:"first_name"` | |
LastName string `gorm:"size:100;not null;" json:"last_name"` | |
Email string `gorm:"size:100;not null;unique" json:"email"` | |
Password string `gorm:"size:100;not null;" json:"password"` | |
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` | |
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` | |
DeletedAt *time.Time `json:"deleted_at,omitempty"` | |
} | |
type PublicUser struct { | |
ID uint64 `gorm:"primary_key;auto_increment" json:"id"` | |
FirstName string `gorm:"size:100;not null;" json:"first_name"` | |
LastName string `gorm:"size:100;not null;" json:"last_name"` | |
} | |
//BeforeSave is a gorm hook | |
func (u *User) BeforeSave() error { | |
hashPassword, err := security.Hash(u.Password) | |
if err != nil { | |
return err | |
} | |
u.Password = string(hashPassword) | |
return nil | |
} | |
type Users []User | |
//So that we dont expose the user's email address and password to the world | |
func (users Users) PublicUsers() []interface{} { | |
result := make([]interface{}, len(users)) | |
for index, user := range users { | |
result[index] = user.PublicUser() | |
} | |
return result | |
} | |
//So that we dont expose the user's email address and password to the world | |
func (u *User) PublicUser() interface{} { | |
return &PublicUser{ | |
ID: u.ID, | |
FirstName: u.FirstName, | |
LastName: u.LastName, | |
} | |
} | |
func (u *User) Prepare() { | |
u.FirstName = html.EscapeString(strings.TrimSpace(u.FirstName)) | |
u.LastName = html.EscapeString(strings.TrimSpace(u.LastName)) | |
u.Email = html.EscapeString(strings.TrimSpace(u.Email)) | |
u.CreatedAt = time.Now() | |
u.UpdatedAt = time.Now() | |
} | |
func (u *User) Validate(action string) map[string]string { | |
var errorMessages = make(map[string]string) | |
var err error | |
switch strings.ToLower(action) { | |
case "update": | |
if u.Email == "" { | |
errorMessages["email_required"] = "email required" | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
errorMessages["invalid_email"] = "email email" | |
} | |
} | |
case "login": | |
if u.Password == "" { | |
errorMessages["password_required"] = "password is required" | |
} | |
if u.Email == "" { | |
errorMessages["email_required"] = "email is required" | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
errorMessages["invalid_email"] = "please provide a valid email" | |
} | |
} | |
case "forgotpassword": | |
if u.Email == "" { | |
errorMessages["email_required"] = "email required" | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
errorMessages["invalid_email"] = "please provide a valid email" | |
} | |
} | |
default: | |
if u.FirstName == "" { | |
errorMessages["firstname_required"] = "first name is required" | |
} | |
if u.LastName == "" { | |
errorMessages["lastname_required"] = "last name is required" | |
} | |
if u.Password == "" { | |
errorMessages["password_required"] = "password is required" | |
} | |
if u.Password != "" && len(u.Password) < 6 { | |
errorMessages["invalid_password"] = "password should be at least 6 characters" | |
} | |
if u.Email == "" { | |
errorMessages["email_required"] = "email is required" | |
} | |
if u.Email != "" { | |
if err = checkmail.ValidateFormat(u.Email); err != nil { | |
errorMessages["invalid_email"] = "please provide a valid email" | |
} | |
} | |
} | |
return errorMessages | |
} |
From the above file, the user's struct is defined that contains the user information, we also added helper functions that will validate and sanitize inputs. A Hash method is called that helps hash password. That is defined in the infrastructure layer.
Gorm is used as the ORM of choice.
The same approach is taken when defining the food entity. You can look up the repo.
Repository
The repository defines a collection of methods that the infrastructure implements. This gives a vivid picture of the number of methods that interact with a given database or a third-party API.
The user's repository will look like this:
package repository | |
import ( | |
"food-app/domain/entity" | |
) | |
type UserRepository interface { | |
SaveUser(*entity.User) (*entity.User, map[string]string) | |
GetUser(uint64) (*entity.User, error) | |
GetUsers() ([]entity.User, error) | |
GetUserByEmailAndPassword(*entity.User) (*entity.User, map[string]string) | |
} |
The methods are defined in an interface. These methods will later be implemented in the infrastructure layer.
Almost the same applies to the food repository here.
Infrastructure Layer
This layer implements the methods defined in the repository. The methods interact with the database or a third-party API. This article will only consider database interaction.
We can see how the user's repository implementation looks like:
package persistence | |
import ( | |
"errors" | |
"food-app/domain/entity" | |
"food-app/domain/repository" | |
"food-app/infrastructure/security" | |
"github.com/jinzhu/gorm" | |
"golang.org/x/crypto/bcrypt" | |
"strings" | |
) | |
type UserRepo struct { | |
db *gorm.DB | |
} | |
func NewUserRepository(db *gorm.DB) *UserRepo { | |
return &UserRepo{db} | |
} | |
//UserRepo implements the repository.UserRepository interface | |
var _ repository.UserRepository = &UserRepo{} | |
func (r *UserRepo) SaveUser(user *entity.User) (*entity.User, map[string]string) { | |
dbErr := map[string]string{} | |
err := r.db.Debug().Create(&user).Error | |
if err != nil { | |
//If the email is already taken | |
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "Duplicate") { | |
dbErr["email_taken"] = "email already taken" | |
return nil, dbErr | |
} | |
//any other db error | |
dbErr["db_error"] = "database error" | |
return nil, dbErr | |
} | |
return user, nil | |
} | |
func (r *UserRepo) GetUser(id uint64) (*entity.User, error) { | |
var user entity.User | |
err := r.db.Debug().Where("id = ?", id).Take(&user).Error | |
if err != nil { | |
return nil, err | |
} | |
if gorm.IsRecordNotFoundError(err) { | |
return nil, errors.New("user not found") | |
} | |
return &user, nil | |
} | |
func (r *UserRepo) GetUsers() ([]entity.User, error) { | |
var users []entity.User | |
err := r.db.Debug().Find(&users).Error | |
if err != nil { | |
return nil, err | |
} | |
if gorm.IsRecordNotFoundError(err) { | |
return nil, errors.New("user not found") | |
} | |
return users, nil | |
} | |
func (r *UserRepo) GetUserByEmailAndPassword(u *entity.User) (*entity.User, map[string]string) { | |
var user entity.User | |
dbErr := map[string]string{} | |
err := r.db.Debug().Where("email = ?", u.Email).Take(&user).Error | |
if gorm.IsRecordNotFoundError(err) { | |
dbErr["no_user"] = "user not found" | |
return nil, dbErr | |
} | |
if err != nil { | |
dbErr["db_error"] = "database error" | |
return nil, dbErr | |
} | |
//Verify the password | |
err = security.VerifyPassword(user.Password, u.Password) | |
if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { | |
dbErr["incorrect_password"] = "incorrect password" | |
return nil, dbErr | |
} | |
return &user, nil | |
} |
Well, you can see that we implemented the methods that were defined in the repository. This was made possible using the UserRepo struct which implements the UserRepository interface, as seen in this line:
//UserRepo implements the repository.UserRepository interface
var _ repository.UserRepository = &UserRepo{}
You can check the repository on how the food repository was implemented here.
So, let's configure our database by creating the db.go file with the content:
package persistence | |
import ( | |
"fmt" | |
"food-app/domain/entity" | |
"food-app/domain/repository" | |
"github.com/jinzhu/gorm" | |
_ "github.com/jinzhu/gorm/dialects/postgres" | |
) | |
type Repositories struct { | |
User repository.UserRepository | |
Food repository.FoodRepository | |
db *gorm.DB | |
} | |
func NewRepositories(Dbdriver, DbUser, DbPassword, DbPort, DbHost, DbName string) (*Repositories, error) { | |
DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", DbHost, DbPort, DbUser, DbName, DbPassword) | |
db, err := gorm.Open(Dbdriver, DBURL) | |
if err != nil { | |
return nil, err | |
} | |
db.LogMode(true) | |
return &Repositories{ | |
User: NewUserRepository(db), | |
Food: NewFoodRepository(db), | |
db: db, | |
}, nil | |
} | |
//closes the database connection | |
func (s *Repositories) Close() error { | |
return s.db.Close() | |
} | |
//This migrate all tables | |
func (s *Repositories) Automigrate() error { | |
return s.db.AutoMigrate(&entity.User{}, &entity.Food{}).Error | |
} |
From the above file, we defined the Repositories struct which holds all the repositories in the application. We have the user and the food repositories. The Repositories also have a db instance, which is passed to the "constructors" of user and food(that is, NewUserRepository and NewFoodRepository).
Application Layer
We have successfully defined the API business logic in our domain. The application connects the domain and the interfaces layers.
We will only consider the user's application. You can check out that of the food in the repo.
This is the user's application:
package application | |
import ( | |
"food-app/domain/entity" | |
"food-app/domain/repository" | |
) | |
type userApp struct { | |
us repository.UserRepository | |
} | |
//UserApp implements the UserAppInterface | |
var _ UserAppInterface = &userApp{} | |
type UserAppInterface interface { | |
SaveUser(*entity.User) (*entity.User, map[string]string) | |
GetUsers() ([]entity.User, error) | |
GetUser(uint64) (*entity.User, error) | |
GetUserByEmailAndPassword(*entity.User) (*entity.User, map[string]string) | |
} | |
func (u *userApp) SaveUser(user *entity.User) (*entity.User, map[string]string) { | |
return u.us.SaveUser(user) | |
} | |
func (u *userApp) GetUser(userId uint64) (*entity.User, error) { | |
return u.us.GetUser(userId) | |
} | |
func (u *userApp) GetUsers() ([]entity.User, error) { | |
return u.us.GetUsers() | |
} | |
func (u *userApp) GetUserByEmailAndPassword(user *entity.User) (*entity.User, map[string]string) { | |
return u.us.GetUserByEmailAndPassword(user) | |
} |
The above have methods to save and retrieve user data. The UserApp struct has the UserRepository interface, which made it possible to call the user repository methods.
Interfaces Layer
The interfaces is the layer that handles HTTP requests and responses. This is where we get incoming requests for authentication, user-related stuff, and food-related stuff.
User Handler
We define methods for saving a user, getting all users and getting a particular user. These are found in the user_handler.go file.
package interfaces | |
import ( | |
"food-app/application" | |
"food-app/domain/entity" | |
"food-app/infrastructure/auth" | |
"github.com/gin-gonic/gin" | |
"net/http" | |
"strconv" | |
) | |
//Users struct defines the dependencies that will be used | |
type Users struct { | |
us application.UserAppInterface | |
rd auth.AuthInterface | |
tk auth.TokenInterface | |
} | |
//Users constructor | |
func NewUsers(us application.UserAppInterface, rd auth.AuthInterface, tk auth.TokenInterface) *Users { | |
return &Users{ | |
us: us, | |
rd: rd, | |
tk: tk, | |
} | |
} | |
func (s *Users) SaveUser(c *gin.Context) { | |
var user entity.User | |
if err := c.ShouldBindJSON(&user); err != nil { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"invalid_json": "invalid json", | |
}) | |
return | |
} | |
//validate the request: | |
validateErr := user.Validate("") | |
if len(validateErr) > 0 { | |
c.JSON(http.StatusUnprocessableEntity, validateErr) | |
return | |
} | |
newUser, err := s.us.SaveUser(&user) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err) | |
return | |
} | |
c.JSON(http.StatusCreated, newUser.PublicUser()) | |
} | |
func (s *Users) GetUsers(c *gin.Context) { | |
users := entity.Users{} //customize user | |
var err error | |
//us, err = application.UserApp.GetUsers() | |
users, err = s.us.GetUsers() | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
c.JSON(http.StatusOK, users.PublicUsers()) | |
} | |
func (s *Users) GetUser(c *gin.Context) { | |
userId, err := strconv.ParseUint(c.Param("user_id"), 10, 64) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, err.Error()) | |
return | |
} | |
user, err := s.us.GetUser(userId) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
c.JSON(http.StatusOK, user.PublicUser()) | |
} |
I want you to observe that when returning the user, we only return a public user(which is defined in the entity). The public user does not have sensitive user details such as email and password.
Authentication Handler
The login_handler takes care of login, logout and refresh token methods. Some methods defined in their respective files are called in this file. Do well to check them out in the repository following their file path.
package interfaces | |
import ( | |
"fmt" | |
"food-app/application" | |
"food-app/domain/entity" | |
"food-app/infrastructure/auth" | |
"github.com/dgrijalva/jwt-go" | |
"github.com/gin-gonic/gin" | |
"net/http" | |
"os" | |
"strconv" | |
) | |
type Authenticate struct { | |
us application.UserAppInterface | |
rd auth.AuthInterface | |
tk auth.TokenInterface | |
} | |
//Authenticate constructor | |
func NewAuthenticate(uApp application.UserAppInterface, rd auth.AuthInterface, tk auth.TokenInterface) *Authenticate { | |
return &Authenticate{ | |
us: uApp, | |
rd: rd, | |
tk: tk, | |
} | |
} | |
func (au *Authenticate) Login(c *gin.Context) { | |
var user *entity.User | |
var tokenErr = map[string]string{} | |
if err := c.ShouldBindJSON(&user); err != nil { | |
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided") | |
return | |
} | |
//validate request: | |
validateUser := user.Validate("login") | |
if len(validateUser) > 0 { | |
c.JSON(http.StatusUnprocessableEntity, validateUser) | |
return | |
} | |
u, userErr := au.us.GetUserByEmailAndPassword(user) | |
if userErr != nil { | |
c.JSON(http.StatusInternalServerError, userErr) | |
return | |
} | |
ts, tErr := au.tk.CreateToken(u.ID) | |
if tErr != nil { | |
tokenErr["token_error"] = tErr.Error() | |
c.JSON(http.StatusUnprocessableEntity, tErr.Error()) | |
return | |
} | |
saveErr := au.rd.CreateAuth(u.ID, ts) | |
if saveErr != nil { | |
c.JSON(http.StatusInternalServerError, saveErr.Error()) | |
return | |
} | |
userData := make(map[string]interface{}) | |
userData["access_token"] = ts.AccessToken | |
userData["refresh_token"] = ts.RefreshToken | |
userData["id"] = u.ID | |
userData["first_name"] = u.FirstName | |
userData["last_name"] = u.LastName | |
c.JSON(http.StatusOK, userData) | |
} | |
func (au *Authenticate) Logout(c *gin.Context) { | |
//check is the user is authenticated first | |
metadata, err := au.tk.ExtractTokenMetadata(c.Request) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "Unauthorized") | |
return | |
} | |
//if the access token exist and it is still valid, then delete both the access token and the refresh token | |
deleteErr := au.rd.DeleteTokens(metadata) | |
if deleteErr != nil { | |
c.JSON(http.StatusUnauthorized, deleteErr.Error()) | |
return | |
} | |
c.JSON(http.StatusOK, "Successfully logged out") | |
} | |
//Refresh is the function that uses the refresh_token to generate new pairs of refresh and access tokens. | |
func (au *Authenticate) Refresh(c *gin.Context) { | |
mapToken := map[string]string{} | |
if err := c.ShouldBindJSON(&mapToken); err != nil { | |
c.JSON(http.StatusUnprocessableEntity, err.Error()) | |
return | |
} | |
refreshToken := mapToken["refresh_token"] | |
//verify the token | |
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) { | |
//Make sure that the token method conform to "SigningMethodHMAC" | |
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) | |
} | |
return []byte(os.Getenv("REFRESH_SECRET")), nil | |
}) | |
//any error may be due to token expiration | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, err.Error()) | |
return | |
} | |
//is token valid? | |
if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid { | |
c.JSON(http.StatusUnauthorized, err) | |
return | |
} | |
//Since token is valid, get the uuid: | |
claims, ok := token.Claims.(jwt.MapClaims) | |
if ok && token.Valid { | |
refreshUuid, ok := claims["refresh_uuid"].(string) //convert the interface to string | |
if !ok { | |
c.JSON(http.StatusUnprocessableEntity, "Cannot get uuid") | |
return | |
} | |
userId, err := strconv.ParseUint(fmt.Sprintf("%.f", claims["user_id"]), 10, 64) | |
if err != nil { | |
c.JSON(http.StatusUnprocessableEntity, "Error occurred") | |
return | |
} | |
//Delete the previous Refresh Token | |
delErr := au.rd.DeleteRefresh(refreshUuid) | |
if delErr != nil { //if any goes wrong | |
c.JSON(http.StatusUnauthorized, "unauthorized") | |
return | |
} | |
//Create new pairs of refresh and access tokens | |
ts, createErr := au.tk.CreateToken(userId) | |
if createErr != nil { | |
c.JSON(http.StatusForbidden, createErr.Error()) | |
return | |
} | |
//save the tokens metadata to redis | |
saveErr := au.rd.CreateAuth(userId, ts) | |
if saveErr != nil { | |
c.JSON(http.StatusForbidden, saveErr.Error()) | |
return | |
} | |
tokens := map[string]string{ | |
"access_token": ts.AccessToken, | |
"refresh_token": ts.RefreshToken, | |
} | |
c.JSON(http.StatusCreated, tokens) | |
} else { | |
c.JSON(http.StatusUnauthorized, "refresh token expired") | |
} | |
} |
Food Handler
In the food_handler.go file, we have methods for basic food crud: creating, reading, updating, and deleting food. The file has explanations of how the code works.
package interfaces | |
import ( | |
"fmt" | |
"food-app/application" | |
"food-app/domain/entity" | |
"food-app/infrastructure/auth" | |
"food-app/interfaces/fileupload" | |
"github.com/gin-gonic/gin" | |
"net/http" | |
"os" | |
"strconv" | |
"time" | |
) | |
type Food struct { | |
foodApp application.FoodAppInterface | |
userApp application.UserAppInterface | |
fileUpload fileupload.UploadFileInterface | |
tk auth.TokenInterface | |
rd auth.AuthInterface | |
} | |
//Food constructor | |
func NewFood(fApp application.FoodAppInterface, uApp application.UserAppInterface, fd fileupload.UploadFileInterface, rd auth.AuthInterface, tk auth.TokenInterface) *Food { | |
return &Food{ | |
foodApp: fApp, | |
userApp: uApp, | |
fileUpload: fd, | |
rd: rd, | |
tk: tk, | |
} | |
} | |
func (fo *Food) SaveFood(c *gin.Context) { | |
//check is the user is authenticated first | |
metadata, err := fo.tk.ExtractTokenMetadata(c.Request) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "unauthorized") | |
return | |
} | |
//lookup the metadata in redis: | |
userId, err := fo.rd.FetchAuth(metadata.TokenUuid) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "unauthorized") | |
return | |
} | |
//We we are using a frontend(vuejs), our errors need to have keys for easy checking, so we use a map to hold our errors | |
var saveFoodError = make(map[string]string) | |
title := c.PostForm("title") | |
description := c.PostForm("description") | |
if fmt.Sprintf("%T", title) != "string" || fmt.Sprintf("%T", description) != "string" { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"invalid_json": "Invalid json", | |
}) | |
return | |
} | |
//We initialize a new food for the purpose of validating: in case the payload is empty or an invalid data type is used | |
emptyFood := entity.Food{} | |
emptyFood.Title = title | |
emptyFood.Description = description | |
saveFoodError = emptyFood.Validate("") | |
if len(saveFoodError) > 0 { | |
c.JSON(http.StatusUnprocessableEntity, saveFoodError) | |
return | |
} | |
file, err := c.FormFile("food_image") | |
if err != nil { | |
saveFoodError["invalid_file"] = "a valid file is required" | |
c.JSON(http.StatusUnprocessableEntity, saveFoodError) | |
return | |
} | |
//check if the user exist | |
_, err = fo.userApp.GetUser(userId) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, "user not found, unauthorized") | |
return | |
} | |
uploadedFile, err := fo.fileUpload.UploadFile(file) | |
if err != nil { | |
saveFoodError["upload_err"] = err.Error() //this error can be any we defined in the UploadFile method | |
c.JSON(http.StatusUnprocessableEntity, saveFoodError) | |
return | |
} | |
var food = entity.Food{} | |
food.UserID = userId | |
food.Title = title | |
food.Description = description | |
food.FoodImage = uploadedFile | |
savedFood, saveErr := fo.foodApp.SaveFood(&food) | |
if saveErr != nil { | |
c.JSON(http.StatusInternalServerError, saveErr) | |
return | |
} | |
c.JSON(http.StatusCreated, savedFood) | |
} | |
func (fo *Food) UpdateFood(c *gin.Context) { | |
//Check if the user is authenticated first | |
metadata, err := fo.tk.ExtractTokenMetadata(c.Request) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "Unauthorized") | |
return | |
} | |
//lookup the metadata in redis: | |
userId, err := fo.rd.FetchAuth(metadata.TokenUuid) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "unauthorized") | |
return | |
} | |
//We we are using a frontend(vuejs), our errors need to have keys for easy checking, so we use a map to hold our errors | |
var updateFoodError = make(map[string]string) | |
foodId, err := strconv.ParseUint(c.Param("food_id"), 10, 64) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, "invalid request") | |
return | |
} | |
//Since it is a multipart form data we sent, we will do a manual check on each item | |
title := c.PostForm("title") | |
description := c.PostForm("description") | |
if fmt.Sprintf("%T", title) != "string" || fmt.Sprintf("%T", description) != "string" { | |
c.JSON(http.StatusUnprocessableEntity, "Invalid json") | |
} | |
//We initialize a new food for the purpose of validating: in case the payload is empty or an invalid data type is used | |
emptyFood := entity.Food{} | |
emptyFood.Title = title | |
emptyFood.Description = description | |
updateFoodError = emptyFood.Validate("update") | |
if len(updateFoodError) > 0 { | |
c.JSON(http.StatusUnprocessableEntity, updateFoodError) | |
return | |
} | |
user, err := fo.userApp.GetUser(userId) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, "user not found, unauthorized") | |
return | |
} | |
//check if the food exist: | |
food, err := fo.foodApp.GetFood(foodId) | |
if err != nil { | |
c.JSON(http.StatusNotFound, err.Error()) | |
return | |
} | |
//if the user id doesnt match with the one we have, dont update. This is the case where an authenticated user tries to update someone else post using postman, curl, etc | |
if user.ID != food.UserID { | |
c.JSON(http.StatusUnauthorized, "you are not the owner of this food") | |
return | |
} | |
//Since this is an update request, a new image may or may not be given. | |
// If not image is given, an error occurs. We know this that is why we ignored the error and instead check if the file is nil. | |
// if not nil, we process the file by calling the "UploadFile" method. | |
// if nil, we used the old one whose path is saved in the database | |
file, _ := c.FormFile("food_image") | |
if file != nil { | |
food.FoodImage, err = fo.fileUpload.UploadFile(file) | |
//since i am using Digital Ocean(DO) Spaces to save image, i am appending my DO url here. You can comment this line since you may be using Digital Ocean Spaces. | |
food.FoodImage = os.Getenv("DO_SPACES_URL") + food.FoodImage | |
if err != nil { | |
c.JSON(http.StatusUnprocessableEntity, gin.H{ | |
"upload_err": err.Error(), | |
}) | |
return | |
} | |
} | |
//we dont need to update user's id | |
food.Title = title | |
food.Description = description | |
food.UpdatedAt = time.Now() | |
updatedFood, dbUpdateErr := fo.foodApp.UpdateFood(food) | |
if dbUpdateErr != nil { | |
c.JSON(http.StatusInternalServerError, dbUpdateErr) | |
return | |
} | |
c.JSON(http.StatusOK, updatedFood) | |
} | |
func (fo *Food) GetAllFood(c *gin.Context) { | |
allfood, err := fo.foodApp.GetAllFood() | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
c.JSON(http.StatusOK, allfood) | |
} | |
func (fo *Food) GetFoodAndCreator(c *gin.Context) { | |
foodId, err := strconv.ParseUint(c.Param("food_id"), 10, 64) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, "invalid request") | |
return | |
} | |
food, err := fo.foodApp.GetFood(foodId) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
user, err := fo.userApp.GetUser(food.UserID) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
foodAndUser := map[string]interface{}{ | |
"food": food, | |
"creator": user.PublicUser(), | |
} | |
c.JSON(http.StatusOK, foodAndUser) | |
} | |
func (fo *Food) DeleteFood(c *gin.Context) { | |
metadata, err := fo.tk.ExtractTokenMetadata(c.Request) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, "Unauthorized") | |
return | |
} | |
foodId, err := strconv.ParseUint(c.Param("food_id"), 10, 64) | |
if err != nil { | |
c.JSON(http.StatusBadRequest, "invalid request") | |
return | |
} | |
_, err = fo.userApp.GetUser(metadata.UserId) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
err = fo.foodApp.DeleteFood(foodId) | |
if err != nil { | |
c.JSON(http.StatusInternalServerError, err.Error()) | |
return | |
} | |
c.JSON(http.StatusOK, "food deleted") | |
} |
Please note, when testing creating or updating food methods via the API using postman, use form-data not JSON. This is because the request type is multipart/form-data.
Running the Application
So, let's test what we've got. We will wire up the routes, connect to the database and start the application.
These will be done in the main.go file defined in the directory root.
package main | |
import ( | |
"food-app/infrastructure/auth" | |
"food-app/infrastructure/persistence" | |
"food-app/interfaces" | |
"food-app/interfaces/fileupload" | |
"food-app/interfaces/middleware" | |
"github.com/gin-gonic/gin" | |
"github.com/joho/godotenv" | |
"log" | |
"os" | |
) | |
func init() { | |
//To load our environmental variables. | |
if err := godotenv.Load(); err != nil { | |
log.Println("no env gotten") | |
} | |
} | |
func main() { | |
dbdriver := os.Getenv("DB_DRIVER") | |
host := os.Getenv("DB_HOST") | |
password := os.Getenv("DB_PASSWORD") | |
user := os.Getenv("DB_USER") | |
dbname := os.Getenv("DB_NAME") | |
port := os.Getenv("DB_PORT") | |
//redis details | |
redis_host := os.Getenv("REDIS_HOST") | |
redis_port := os.Getenv("REDIS_PORT") | |
redis_password := os.Getenv("REDIS_PASSWORD") | |
services, err := persistence.NewRepositories(dbdriver, user, password, port, host, dbname) | |
if err != nil { | |
panic(err) | |
} | |
defer services.Close() | |
services.Automigrate() | |
redisService, err := auth.NewRedisDB(redis_host, redis_port, redis_password) | |
if err != nil { | |
log.Fatal(err) | |
} | |
tk := auth.NewToken() | |
fd := fileupload.NewFileUpload() | |
users := interfaces.NewUsers(services.User, redisService.Auth, tk) | |
foods := interfaces.NewFood(services.Food, services.User, fd, redisService.Auth, tk) | |
authenticate := interfaces.NewAuthenticate(services.User, redisService.Auth, tk) | |
r := gin.Default() | |
r.Use(middleware.CORSMiddleware()) //For CORS | |
//user routes | |
r.POST("/users", users.SaveUser) | |
r.GET("/users", users.GetUsers) | |
r.GET("/users/:user_id", users.GetUser) | |
//post routes | |
r.POST("/food", middleware.AuthMiddleware(), middleware.MaxSizeAllowed(8192000), foods.SaveFood) | |
r.PUT("/food/:food_id", middleware.AuthMiddleware(), middleware.MaxSizeAllowed(8192000), foods.UpdateFood) | |
r.GET("/food/:food_id", foods.GetFoodAndCreator) | |
r.DELETE("/food/:food_id", middleware.AuthMiddleware(), foods.DeleteFood) | |
r.GET("/food", foods.GetAllFood) | |
//authentication routes | |
r.POST("/login", authenticate.Login) | |
r.POST("/logout", authenticate.Logout) | |
r.POST("/refresh", authenticate.Refresh) | |
//Starting the application | |
app_port := os.Getenv("PORT") //using heroku host | |
if app_port == "" { | |
app_port = "8888" //localhost | |
} | |
log.Fatal(r.Run(":"+app_port)) | |
} |
The router(r) is of type Engine from the gin package we are using.
Middleware
As seen from the above file, Some routes have restrictions. The AuthMiddleware restricts access to an unauthenticated user. The CORSMiddleware enables data transfer from different domains. This is useful because VueJS is used for the frontend of this application and it points to a different domain.
The MaxSizeAllowed middleware stops any file with size above the one the middleware specifies. Since the food implementation requires file upload, the middleware stops files greater than the specified to be read into memory. This helps to prevent hackers from uploading an unreasonable huge file and slowing down the application. The middleware package is defined in the interfaces layer.
package middleware | |
import ( | |
"bytes" | |
"food-app/infrastructure/auth" | |
"github.com/gin-gonic/gin" | |
"io/ioutil" | |
"net/http" | |
) | |
func AuthMiddleware() gin.HandlerFunc { | |
return func(c *gin.Context) { | |
err := auth.TokenValid(c.Request) | |
if err != nil { | |
c.JSON(http.StatusUnauthorized, gin.H{ | |
"status": http.StatusUnauthorized, | |
"error": err.Error(), | |
}) | |
c.Abort() | |
return | |
} | |
c.Next() | |
} | |
} | |
func CORSMiddleware() gin.HandlerFunc { | |
return func(c *gin.Context) { | |
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | |
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | |
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | |
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE") | |
if c.Request.Method == "OPTIONS" { | |
c.AbortWithStatus(204) | |
return | |
} | |
c.Next() | |
} | |
} | |
//Avoid a large file from loading into memory | |
//If the file size is greater than 8MB dont allow it to even load into memory and waste our time. | |
func MaxSizeAllowed(n int64) gin.HandlerFunc { | |
return func(c *gin.Context) { | |
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n) | |
buff, errRead := c.GetRawData() | |
if errRead != nil { | |
//c.JSON(http.StatusRequestEntityTooLarge,"too large") | |
c.JSON(http.StatusRequestEntityTooLarge, gin.H{ | |
"status": http.StatusRequestEntityTooLarge, | |
"upload_err": "too large: upload an image less than 8MB", | |
}) | |
c.Abort() | |
return | |
} | |
buf := bytes.NewBuffer(buff) | |
c.Request.Body = ioutil.NopCloser(buf) | |
} | |
} |
We can now run the application using:
go run main.go
Bonus
- Vue and VueX are used to consume the API. Get the repository here, You can also visit the url and play with it.
- Test cases are written for most of the functionality. If you have time, you can add to it. To achieve unit testing for each of the methods in our handler, we created a mock package(in the utils directory) that mocks all the dependencies that are used in the handler methods.
- Circle CI is used for Continuous Integration.
- Heroku is used to deploy the API
- Netlify is used to deploy the Frontend.
Conclusion
I hope you didn't have a hard time following this guideline on how to use DDD in when building a golang application. If you have questions or any observations, please don't hesitate to use the comment section. As said early, the author is not an expert on this. He simply wrote this article based on his use case.
Get the API code here.
Get the Frontend code here.
You can also visit the application url and play with it.
Check out other articles on medium here.
You can as well follow on twitter
Happy DDDing.
标签:Driven,Domain,return,nil,err,food,Golang,user,http From: https://www.cnblogs.com/codestack/p/18147676