535 lines
18 KiB
Markdown
535 lines
18 KiB
Markdown
<p align="center">
|
|
<img src="./logo.png#gh-light-mode-only" alt="Goyave Logo" width="550"/>
|
|
<img src="./logo_dark.png#gh-dark-mode-only" alt="Goyave Logo" width="550"/>
|
|
</p>
|
|
|
|
<p align="center">
|
|
<a href="https://github.com/ixugo/goddd/releases"><img src="https://img.shields.io/github/v/release/ixugo/goddd?include_prereleases" alt="Version"/></a>
|
|
<a href="https://github.com/ixugo/goddd/blob/master/LICENSE.txt"><img src="https://img.shields.io/dub/l/vibe-d.svg" alt="License"/></a>
|
|
<a href="https://gin-gonic.com"><img width=30px src="https://avatars.githubusercontent.com/u/7894478?s=48&v=4" alt="GIN"/></a>
|
|
<a href="https://gorm.io"><img width=70px src="https://gorm.io/gorm.svg" alt="GORM"/></a>
|
|
|
|
</p>
|
|
|
|
[English](./README.md) | [简体中文](./README_zh.md)
|
|
|
|
# Enterprise REST API Template
|
|
|
|
This is a complete CRUD solution focused on REST API.
|
|
|
|
The goal of GoDDD is to:
|
|
|
|
+ Provide a clean architecture suitable for small and medium-sized projects.
|
|
+ Provide a modular structure for quickly starting a project, focusing on business development.
|
|
+ Simplify projects, making development more efficient and enjoyable.
|
|
|
|
If you think the above description fits your needs, then let's get started quickly.
|
|
|
|
Supports [code generation](github.com/ixugo/godddx).
|
|
|
|
Supports [event bus/transaction messages](github.com/ixugo/nsqite).
|
|
|
|
## Quick start
|
|
|
|
1. Golang version >= 1.23.0
|
|
2. `git clone github.com/ixugo/goddd`
|
|
3. `cd goddd && go build -o goddd ./cmd/server && ./goddd`
|
|
4. Open a new terminal and access `curl http://localhost:8080/health`
|
|
|
|
5. Modify the module package name:
|
|
`make rename name=github.com/name/project`
|
|
Replace `name` with your actual module name.
|
|
|
|
|
|
|
|
|
|
## References
|
|
|
|
[Google API Design Guide](https://google-cloud.gitbook.io/api-design-guide)
|
|
|
|
Best Practices for This Project: https://github.com/gowvp/gb28181
|
|
|
|
|
|
## Directory Structure
|
|
|
|
```bash
|
|
.
|
|
├── cmd Executable program
|
|
│ └── server
|
|
├── configs Configuration files
|
|
├── docs Design/User documentation
|
|
├── internal Private business
|
|
│ ├── conf Configuration models
|
|
│ ├── core Business domain
|
|
│ │ └── version Actual business
|
|
│ │ └── store
|
|
│ │ └── versiondb Database operations
|
|
│ ├── data Database initialization
|
|
│ └── web
|
|
│ └── api RESTful API
|
|
└── pkg Dependencies
|
|
```
|
|
|
|
## Project Description
|
|
|
|
1. Components strongly relied upon by the program will trigger a panic on error, so that issues are resolved as quickly as possible.
|
|
|
|
2. The core directory represents the business domain, containing domain models and domain business functions.
|
|
|
|
3. The store is the database operation module, dependent on models with dependency inversion towards the core, avoiding the need to define models at each layer.
|
|
|
|
4. Input/output parameters in the API layer may directly depend on models defined in the core layer, with input and output models distinguished by appending `Input/Output` to the model names.
|
|
|
|
|
|
## Request Parameter Wrapping
|
|
|
|
This project uses GIN as the web framework, and the route functions need to implement `gin.HandlerFunc`. The first issue encountered when implementing API functions is binding parameters. Almost every function involves deserialization, and the function heads are cluttered with `ctx.ShouldBindJSON` and similar code.
|
|
|
|
To follow the DRY (Don't Repeat Yourself) design principle, we reduce repetitive code to improve maintainability and reusability. The project wraps `web.WrapH`, which returns a `gin.HandlerFunc`. The parameters for `web.WrapH` are similar to gRPC, with a signature like `func(ctx *gin.Context, in *struct{}) (*Output, error)`.
|
|
|
|
`WrapH` internally recognizes POST/PUT/DELETE/PATCH requests and binds the Request Body, while GET requests bind Request URL parameters.
|
|
|
|
The second parameter of the input must be a pointer, and `*struct{}` is used when no parameters need to be bound. When defining the structure, especially note that the struct tags should be `json` or `form`. More details are available in the GIN framework's parameter binding documentation.
|
|
|
|
+ `json`: Can bind request body parameters.
|
|
+ `form`: Can bind query parameters.
|
|
|
|
The first parameter of the return value is the actual response body content, and it is recommended to avoid using `any`. The type can be either a value or a pointer, providing more flexibility.
|
|
|
|
When parameters exist in multiple places, such as route parameters, query parameters, and request body parameters, you can implement a new `web.WrapH2` or directly implement `gin.HandlerFunc`.
|
|
|
|
Here are two code examples:
|
|
|
|
|
|
```go
|
|
func findUser(ctx *gin.Context) {
|
|
var in findUserInput
|
|
if err := ctx.ShouldBindQuery(&in);err!=nil {
|
|
ctx.JSON(...)
|
|
return
|
|
}
|
|
out,err := serviceFunc(in)
|
|
// ....
|
|
}
|
|
```
|
|
|
|
```go
|
|
func findUsers(ctx *gin.Context, in *Input) (*Output, error) {
|
|
return serviceFunc(in)
|
|
}
|
|
```
|
|
|
|
## Response Parameter Wrapping
|
|
|
|
Clearly defining the response type can make the code easier to understand. The goal is to improve code readability and maintainability by paying attention to more details.
|
|
|
|
The web.WrapH wrapper defaults to returning a response with the application/json content type.
|
|
|
|
During development, new colleagues may forget the return statement when implementing gin.HandlerFunc. Using web.WrapH ensures that the return statement is not omitted.
|
|
|
|
Here are two code examples:
|
|
|
|
```go
|
|
func findUsers(ctx *gin.Context) {
|
|
// Maybe out is obtained from the business layer
|
|
// At this point, you need to find the response body inside the function
|
|
out, err := serviceFunc()
|
|
if err != nil {
|
|
ctx.JSON(...)
|
|
return
|
|
}
|
|
ctx.JSON(out)
|
|
}
|
|
```
|
|
|
|
```go
|
|
func findUsers(ctx *gin.Context, in *Input) (*Output, error) {
|
|
return serviceFunc(in)
|
|
}
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
From the above code, we can see that errors are directly returned. But doesn't this expose the underlying error information to the user? And what about the HTTP status code for errors?
|
|
|
|
In fact, `web.Warn` does some additional work. For example, when there is an error during binding, it can pinpoint the specific error cause: Is the type wrong? Which property is incorrect? For example, when responding, we can extract information from the `err` and return the corresponding HTTP status code. Let's take a closer look at error handling.
|
|
|
|
`pkg/web` is an HTTP-related handling package, which includes middleware, response handling, error handling, authentication, logging, rate limiting, metrics, performance analysis, input validation, and more.
|
|
|
|
We define a custom `Error` type, where `reason` represents the error cause. Some third-party APIs also use a `Code`.
|
|
|
|
When designing the project, we considered that status codes might be hard to interpret, for example, error `10020`—what does that error mean? Therefore, we defined `reason`, which should describe the error cause in a concise, camel-case English format. If you just want to use the status code, then use the HTTP StatusCode.
|
|
|
|
`msg` should be an error description in the developer's native language, while `reason` is used internally by the program, and `msg` is for user-friendly messaging. `details` is an extension of the error, providing additional information for developers. It can describe solutions to the error, provide documentation, give more detailed error information, or even expose lower-level errors.
|
|
|
|
In front-end and back-end separated projects, when the front-end encounters an error, they often need to ask the back-end what happened. Through `details`, the front-end can reduce the number of inquiries.
|
|
|
|
In the `web.WrapH` wrapper, errors are actually handled by calling `web.Fail(err)`. This method determines which HTTP status code should be returned based on the `reason`. Developers can implement more HTTP status code extensions in the `pkg/web/error.go` file through the `HTTPCode()` function. By default, three status codes are provided: 200, 400, and 401.
|
|
|
|
`details` should only be visible in development mode. You can set the release mode using `web.SetRelease()`, in which case `details` will not be included in the HTTP response body.
|
|
|
|
|
|
Functions exported from the core layer or errors returned from the API layer should return errors of type `reason.Error`.
|
|
|
|
In the wrapped `web.WrapH`, errors are correctly logged and returned to the front-end.
|
|
|
|
```go
|
|
func findUser(in *Input) (*Output, error) {
|
|
// Database operation error
|
|
if err != nil {
|
|
return nil, reason.ErrDB.SetMsg() // The response type is a DB layer error, and the Msg function can modify the user-friendly message
|
|
}
|
|
// Business logic error
|
|
if err != nil {
|
|
return nil, reason.ErrServer.Withf("err[%s] ....", err) // Withf can write details to provide more hints to the developer
|
|
}
|
|
}
|
|
```
|
|
|
|
|
|
## Makefile
|
|
|
|
For Windows systems, please use the Git Bash terminal to run the Makefile instead of the default cmd/powershell terminal, as issues may arise.
|
|
|
|
Use `make` or `make help` to get more help.
|
|
|
|
When writing a Makefile, add comments above each command in the format `## <command>: <description>` for readability, with available parameters provided in the Makefile. The goal is to make `make help` output more informative.
|
|
|
|
Some default operations are provided in the Makefile to assist with rapid development.
|
|
|
|
`make confirm` confirms the next step.
|
|
|
|
`make title content=Title` highlights a title in the output.
|
|
|
|
`make info` fetches build version information.
|
|
|
|
**Versioning Rules in the Makefile**
|
|
|
|
1. Git tags are used for versioning, in the format v1.0.0.
|
|
|
|
2. If the current commit lacks a tag, the closest tag is found, and the number of commits from that tag is calculated. For example, if the latest tag is v1.0.1, and there have been 10 commits since, the version number becomes v1.0.11 (v1.0.1 + 10 commits).
|
|
|
|
3. If there are no tags, the default version is v0.0.0, with the minor version incremented based on the number of commits.
|
|
|
|
|
|
## How to use the library?
|
|
|
|
### hook.UseCache Temporary cache
|
|
|
|
old
|
|
|
|
```go
|
|
cache := make(map[string]string)
|
|
for i := range 10 {
|
|
v, ok := cache[i]
|
|
if ok {
|
|
// Business processing
|
|
continue
|
|
}
|
|
v,err := fn()
|
|
if err == nil {
|
|
cache[v.ID] = v
|
|
}
|
|
// Business processing
|
|
}
|
|
```
|
|
|
|
new
|
|
|
|
```go
|
|
cacheFn := hook.UseCache(fn)
|
|
for i := range 10 {
|
|
v,_,err := cacheFn(i)
|
|
if err == nil {
|
|
// Business processing
|
|
}
|
|
}
|
|
```
|
|
|
|
### hook.UseTiming Log the cost of function computation
|
|
|
|
old
|
|
|
|
```go
|
|
now := time.Now()
|
|
// Business logic is intermingled with time calculation
|
|
if sub :=time.Since(now); sub > time.Second {
|
|
slog.Error("func name", "cost", cost)
|
|
}else {
|
|
slog.Debug("func name", "cost", cost)
|
|
}
|
|
```
|
|
|
|
new
|
|
|
|
```go
|
|
cost := hook.UseTiming(time.Second)
|
|
defer cost()
|
|
|
|
// Business processing
|
|
```
|
|
|
|
### hook.UseTimer Timer with flexible intervals
|
|
|
|
old
|
|
|
|
```go
|
|
func scheduleTask() {
|
|
for {
|
|
// Business processing
|
|
processTask()
|
|
|
|
// Complex time calculation logic mixed with business logic
|
|
now := time.Now()
|
|
nextRun := time.Date(now.Year(), now.Month(), now.Day()+1, 2, 0, 0, 0, now.Location()) // Run at 2 AM tomorrow
|
|
if nextRun.Before(now) {
|
|
nextRun = nextRun.Add(24 * time.Hour)
|
|
}
|
|
time.Sleep(nextRun.Sub(now))
|
|
}
|
|
}
|
|
```
|
|
|
|
new
|
|
|
|
```go
|
|
func scheduleTask(ctx context.Context) {
|
|
hook.UseTimer(ctx, processTask, func() time.Duration {
|
|
return hook.NextTimeTomorrow(2, 0, 0) // Run at 2 AM every day
|
|
})
|
|
}
|
|
|
|
// Or for immediate execution with different intervals
|
|
func scheduleTaskWithFirstRun(ctx context.Context) {
|
|
nextTime := hook.NextTimeWithFirst(
|
|
time.Second, // Run immediately (after 1 second)
|
|
func() time.Duration {
|
|
return 10 * time.Minute // Then run every 10 minutes
|
|
},
|
|
)
|
|
hook.UseTimer(ctx, processTask, nextTime)
|
|
}
|
|
```
|
|
|
|
**Check out the source code in pkg/hook for more hooks.**
|
|
|
|
|
|
|
|
## Quick Start
|
|
|
|
Example business logic:
|
|
|
|
Assume we want to implement version management. The CRUD steps are as follows:
|
|
|
|
Under "internal" - "core," create the "version" directory, then create `model.go` and define the domain model representing the database table structure.
|
|
|
|
Create `core.go` and add the following content:
|
|
|
|
```go
|
|
package version
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Storer Interface for dependency inversion in data persistence.
|
|
type Storer interface {
|
|
First(*Version) error
|
|
Add(*Version) error
|
|
}
|
|
|
|
// Core Business object
|
|
type Core struct {
|
|
Storer Storer
|
|
}
|
|
|
|
// NewCore Creates a business object.
|
|
func NewCore(store Storer) *Core {
|
|
return &Core{
|
|
Storer: store,
|
|
}
|
|
}
|
|
|
|
// IsAutoMigrate Checks if table migration is required
|
|
// Compares the hard-coded database table version with the stored version.
|
|
func (c *Core) IsAutoMigrate(currentVer, remark string) bool {
|
|
var ver Version
|
|
if err := c.Storer.First(&ver); err != nil {
|
|
isMigrate := true
|
|
c.IsMigrate = &isMigrate
|
|
return isMigrate
|
|
}
|
|
isMigrate := compareVersionFunc(currentVer, ver.Version, func(a, b string) bool {
|
|
return a > b
|
|
})
|
|
c.IsMigrate = &isMigrate
|
|
return isMigrate
|
|
}
|
|
|
|
func compareVersionFunc(a, b string, f func(a, b string) bool) bool {
|
|
s1 := versionToStr(a)
|
|
s2 := versionToStr(b)
|
|
if len(s1) != len(s2) {
|
|
return true
|
|
}
|
|
return f(s1, s2)
|
|
}
|
|
|
|
func versionToStr(str string) string {
|
|
var result strings.Builder
|
|
arr := strings.Split(str, ".")
|
|
for _, item := range arr {
|
|
if idx := strings.Index(item, "-"); idx != -1 {
|
|
item = item[0:idx]
|
|
}
|
|
result.WriteString(fmt.Sprintf("%03s", item))
|
|
}
|
|
return result.String()
|
|
}
|
|
```
|
|
|
|
Under "store/versiondb," create the `db.go` file with the following content:
|
|
|
|
```go
|
|
type DB struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewDB(db *gorm.DB) DB {
|
|
return DB{db: db}
|
|
}
|
|
|
|
// AutoMigrate Table migration.
|
|
func (d DB) AutoMigrate(ok bool) DB {
|
|
if !ok {
|
|
return d
|
|
}
|
|
if err := d.db.AutoMigrate(
|
|
new(version.Version),
|
|
); err != nil {
|
|
panic(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (d DB) First(v *version.Version) error {
|
|
return d.db.Order("id DESC").First(v).Error
|
|
}
|
|
|
|
func (d DB) Add(v *version.Version) error {
|
|
return d.db.Create(v).Error
|
|
}
|
|
```
|
|
|
|
In the API layer, inject dependencies by adding a function in `web/api/provider.go` to inject the business object into Usecase:
|
|
|
|
```go
|
|
var ProviderSet = wire.NewSet(
|
|
wire.Struct(new(Usecase), "*"),
|
|
NewHTTPHandler,
|
|
NewVersion,
|
|
)
|
|
|
|
func NewVersion(db *gorm.DB) *version.Core {
|
|
vdb := versiondb.NewDB(db)
|
|
core := version.NewCore(vdb)
|
|
isOK := core.IsAutoMigrate(dbVersion, dbRemark)
|
|
vdb.AutoMigrate(isOK)
|
|
if isOK {
|
|
slog.Info("Updating database schema")
|
|
if err := core.RecordVersion(dbVersion, dbRemark); err != nil {
|
|
slog.Error("RecordVersion", "err", err)
|
|
}
|
|
}
|
|
return core
|
|
}
|
|
```
|
|
|
|
Create a new `version.go` file in the API layer with the following content:
|
|
|
|
```go
|
|
// VersionAPI Namespace for version business functions.
|
|
type VersionAPI struct {
|
|
ver *version.Core
|
|
}
|
|
|
|
func NewVersionAPI(ver *version.Core) VersionAPI {
|
|
return VersionAPI{ver: ver}
|
|
}
|
|
// registerVersion Registers business interface with the router.
|
|
func registerVersion(r gin.IRouter, verAPI VersionAPI, handler ...gin.HandlerFunc) {
|
|
ver := r.Group("/version", handler...)
|
|
ver.GET("", web.WrapH(verAPI.getVersion))
|
|
}
|
|
|
|
func (v VersionAPI) getVersion(_ *gin.Context, _ *struct{}) (any, error) {
|
|
return gin.H{"msg": "test"}, nil
|
|
}
|
|
```
|
|
|
|
## FAQ
|
|
|
|
> Why not define models in each layer separately?
|
|
|
|
This is a trade-off between development efficiency and decoupling, balancing code readability and efficiency.
|
|
|
|
> Where should API layer parameter models and table mapping models be defined?
|
|
|
|
Understanding the dependency relationships between layers is crucial. The API directly depends on the core, while the DB layer is inverted to depend on the core. Thus, domain models are defined in the core, and input/output parameter models can also be defined in the core. If they are unused in the core, defining them in the API layer is fine too.
|
|
|
|
> Why does the API layer directly depend on the core layer rather than an interface?
|
|
|
|
Interfaces aim to decouple, but in practice, it is more common to replace the API layer than the core layer.
|
|
|
|
The API only retrieves parameters and returns response parameters, doing the minimum necessary to facilitate the transition from HTTP to GRPC.
|
|
|
|
Design for the future, but program for the present. Increasing development efficiency now allows for a better approach in the future when needed.
|
|
|
|
> Why is the DB layer inverted to depend on the core?
|
|
|
|
Data persistence is not independent; it serves the business. That is, persistence serves the
|
|
|
|
business and depends on it.
|
|
|
|
Through dependency inversion, other databases, such as Redis cache, can be inserted between business operations.
|
|
|
|
> Why suffix input/output models with `Input/Output`?
|
|
|
|
Convention is preferable to configuration. Some projects use `Request/Response` as suffixes to standardize parameter names.
|
|
|
|
Of course, output parameters can also serve as input, and you can define an alias or use them directly.
|
|
|
|
Frequently, we want clarity on what we're doing and why. This FAQ aims to offer some insight.
|
|
|
|
> How to write business plugins for GoDDD?
|
|
|
|
```go
|
|
// RegisterVersion Some general business functions are depended upon by other business functions, such as table version control, dictionary, verification code, scheduled tasks, user management, etc.
|
|
// Conventionally, write functions in the format Register<Core>, injecting three parameters: gin router, namespace, and middleware.
|
|
// Refer to project code for specifics.
|
|
func RegisterVersion(r gin.IRouter, verAPI VersionAPI, handler ...gin.HandlerFunc) {
|
|
ver := r.Group("/version", handler...)
|
|
ver.GET("", web.WrapH(verAPI.getVersion))
|
|
}
|
|
```
|
|
|
|
## Table Migration
|
|
|
|
Executing table migration on every program start is too slow.
|
|
|
|
Therefore, migration control is implemented through the version table, so migration only occurs when the database table version is outdated. Modify the `dbVersion` in api/db.go to control the version number.
|
|
|
|
|
|
## Custom Configuration Directory
|
|
|
|
The default configuration directory is `configs`, located in the same directory as the executable. You can also specify other configuration directories.
|
|
|
|
`./bin -conf ./configs`
|
|
|
|
## Main Project Dependencies
|
|
|
|
+ gin
|
|
+ gorm
|
|
+ slog / zap
|
|
+ wire |