چارچوب Go Echo همراه با DDD و CQRS: قسمت اول


۰


در این سری مقالات، قصد دارم تجربه خودم را در ساخت اپلیکیشن‌ها با زبان Golang با استفاده از Echo Framework و اصولی مثل DDD (Domain-Driven Design) و CQRS (Command Query Responsibility Segregation) به اشتراک بگذارم. انتخاب این تکنولوژی‌ها و رویکردها اتفاقی نیست. Golang به خاطر کارایی و سادگی‌اش شناخته شده است، Echo به بهبود سرعت و سهولت در توسعه برنامه‌های وب کمک می‌کند، و DDD و CQRS امکان ساخت سیستم‌های مقیاس‌پذیر و قابل نگهداری را می‌دهند.

در عمل، بارها دیدم که خیلی از توسعه‌دهندگان به درستی اصول DDD را درک نکرده‌اند، به‌خصوص در بخش تفکیک لایه‌ها. خطای رایج این است که لایه دامنه (domain) و لایه زیرساخت (infrastructure) به خوبی از هم جدا نمی‌شوند و Entityهای دامنه به‌عنوان مدل‌های لایه زیرساخت اشتباه گرفته می‌شوند.

در این مقاله روی پیاده‌سازی عملی تمرکز می‌کنیم: ساختار پوشه‌ها و راه‌اندازی یک سرور HTTP برای پروژه – بک‌اند یک اپلیکیشن. در اینجا زیاد وارد نظریه‌های DDD و CQRS نمی‌شویم، چون پیش‌تر مطالب زیادی نوشته شده و اطلاعات براحتی در دسترس است.

ساختار دایرکتوری

اول، ساختار پوشه‌های پروژه آینده‌مان را بررسی کنیم. این ساختار به ما کمک می‌کند تا کدها را طبق اصول DDD و CQRS به خوبی سازماندهی کنیم.

1.  
2├── cmd                         # رابط خط فرمان و نقطه شروع اصلی اپلیکیشن  
3│   └── myapp                   # پوشه اصلی اپلیکیشن  
4│       ├── cli                 # کد مختص CLI، شامل تعریف دستورات و پارسرها  
5│       │   ├── cli.go          # تنظیم CLI و پیکربندی دستورات  
6│       └── main.go             # نقطه ورود برنامه  
7├── internal                    # کد داخلی برنامه – دسترسی از بیرون ندارد  
8│   ├── application             # لایه Application: هماهنگی جریان برنامه، پیکربندی و پیاده‌سازی CQRS  
9│   │   ├── app.go              # تنظیمات و مقداردهی اولیه اپلیکیشن  
10│   │   ├── command             # بخش Command در CQRS: اجرای دستورات  
11│   │   │   └── command.go      # هندل‌کردن Command در CQRS  
12│   │   └── query               # بخش Query در CQRS: درخواست‌های خواندن داده  
13│   │       └── query.go        # هندل‌کردن Query در CQRS  
14│   ├── domain                  # منطق اصلی دامنه: Entityها، Aggregateها، سرویس‌ها و اینترفیس Repository  
15│   │   ├── aggregate           # Aggregateهای دامنه، مجموعه‌ای از Entity‌ها که با هم پردازش می‌شوند  
16│   │   ├── entity              # Entity‌های دامنه، نهادهای اصلی کسب‌وکار  
17│   │   ├── repository          # اینترفیس‌های Repository دامنه، تعریف‌های انتزاعی برای دسترسی به داده  
18│   │   └── service             # سرویس‌های دامنه: منطق کسب‌وکار که به‌خوبی در Entity یا Aggregate جای نمی‌گیرد  
19│   ├── genmocks.go             # تولید mock برای تست‌های واحد و یکپارچه  
20│   ├── infrastructure          # لایه زیرساخت: فریمورک‌ها، درایورها و ابزارهای فنی  
21│   │   ├── api                 # APIها، به‌خصوص HTTP برای تعامل وب  
22│   │   │   └── rest            # پیاده‌سازی‌های اختصاصی REST: سرورها، هندلرها، middleware  
23│   │   │       ├── handler     # هندلرهای REST، جهت پردازش درخواست‌های HTTP ورودی  
24│   │   │       ├── middleware  # میان‌افزار REST، مثلاً لاگینگ یا تأیید هویت  
25│   │   │       └── validator   # اعتبارسنجی درخواست‌های REST  
26│   │   ├── decorator           # دکوراتورها برای افزودن یا تغییر رفتار (مثلاً لاگینگ، متریک‌ها)  
27│   │   │   ├── decorator.go    # دکوراتورهای پایه، برای موضوعات سراسری  
28│   │   │   └── logging.go      # دکوراتور لاگینگ  
29│   │   └── pgsql               # پیاده‌سازی PostgreSQL: مدل‌ها و Repositoryهای دیتابیس  
30│   │       ├── model           # مدل‌های داده برای PostgreSQL  
31│   │       └── repository      # پیاده‌سازی Repository دامنه برای PostgreSQL  
32│   └── mocks                   # Mockهای تست، خودکار یا دستی برای تست واحد  
33└── pkg                         # کتابخانه‌ها و ابزارهای مشترک، قابل استفاده مجدد در پروژه‌های دیگر  

کد این ساختار روی گیت‌هاب من موجود است: https://github.com/i4erkasov/go-ddd-cqrs

پیاده‌سازی HTTP Server با Echo Framework

برای شروع توسعه سرور HTTP، ابتدا یک فایل main.go در مسیر ./cmd/myapp می‌سازیم. فعلاً این فایل را خالی می‌گذاریم و روی کار اصلی تمرکز می‌کنیم.

Echo Framework اساس سرور HTTP ماست. در معماری DDD، سرور HTTP در لایه زیرساخت قرار دارد. یعنی رابطی بین دنیای بیرون و اپلیکیشن ما است.

برای سامان‌دهی کد سرور، ساختار زیر را در ./internal/infrastructure/api/rest ایجاد می‌کنیم:

  • server.go — پیاده‌سازی سرور
  • route.go — تعریف مسیرها (routes)

در پوشه‌های handler، middleware و validator، کامپوننت‌های مربوطه گذاشته می‌شوند:

  • Handler (handler/handler.go): فایل اصلی هندلرها؛ هر هندلر پاسخ به یک درخواست HTTP را بر عهده دارد.

  • Middleware (middleware/middleware.go): شامل میدل‌ورها، مثلاً برای لاگینگ یا احراز هویت.

  • Validator (validator/validator.go): اعتبارسنج‌های سفارشی که درخواست‌های ورودی HTTP را با شرایط مورد انتظار مقایسه می‌کنند.

ساختار دایرکتوری rest به شکل زیر است:

1.  
2├── handler  
3│   └── handler.go  
4├── middleware  
5│   └── middleware.go  
6├── routes.go  
7├── server.go  
8└── validator  
9    └── validator.go  

در handler.go ساختار و سازنده زیر را تعریف می‌کنیم (در ادامه پروژه گسترشش می‌دهیم):

1package handler
2
3type Handler struct {}
4
5func New() *Handler {
6   return &Handler{}
7}

مشابه همین کار را برای middleware.go انجام می‌دهیم:

1package middleware
2
3type Middleware struct {}
4
5func New() *Middleware {
6   return &Middleware{}
7}

در validator.go از کتابخانه https://github.com/go-playground/validator استفاده می‌کنیم اما باید آن را با اینترفیس echo.Validator سازگار کنیم. این کار را با یک wrapper انجام می‌دهیم:

1package validator
2
3import (
4   "github.com/go-playground/validator/v10"
5   "github.com/labstack/echo/v4"
6)
7
8// پیاده‌سازی wrapper
9type wrapper struct {
10   validator *validator.Validate
11}
12
13func New() echo.Validator {
14   return &wrapper{
15      validator: validator.New(),
16   }
17}
18
19// اعتبارسنجی داده‌ها
20func (v *wrapper) Validate(i interface{}) error {
21   return v.validator.Struct(i)
22}

حالا سراغ server.go می‌رویم.

قبلاً بگویم که در پیاده‌سازی سرور از این پکیج‌ها استفاده می‌کنیم:

ساختار سرور به صورت زیر است:

1type Server struct {
2   echo *echo.Echo
3   log  *zap.Logger
4   cfg  *viper.Viper
5}

ما به دو متد عمومی نیاز داریم: New به عنوان سازنده‌ی سرور و Start که سرور را اجرا می‌کند. قبل از آن، سرور را با استفاده از Viper پیکربندی می‌کنیم.

یک پوشه .bin در ریشه پروژه بسازید و داخل آن فایل کانفیگ config.dev.yaml را ایجاد و تنظیمات زیر را اضافه کنید:

1app:
2  environment: "development"
3  api:
4    rest:
5      host: "127.0.0.1"
6      port: "8088"
7      setting:
8        debug: true
9        hide_banner: false
10        hide_port: false

حالا بیایید route.go را تعریف کنیم:

1package rest
2
3import (
4   "net/http"
5
6   "github.com/labstack/echo/v4"
7)
8
9func (s *Server) routes(h *handler.Handler, m *middleware.Middleware) {
10
11}

اکنون که مقدمات آماده است، سرور را پیاده‌سازی می‌کنیم. برای خوانایی کد، پیاده‌سازی را به چند متد خصوصی دسته‌بندی کردم: start، configure و handleErrors. کد نهایی به این شکل است:

1package rest
2
3import (
4   "context"
5   "net"
6   "net/http"
7
8   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest/handler"
9   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest/middleware"
10   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest/validator"
11   "github.com/juju/errors"
12   "github.com/labstack/echo/v4"
13   "github.com/spf13/viper"
14   "go.uber.org/zap"
15)
16
17type Server struct {
18   echo *echo.Echo
19   log  *zap.Logger
20   cfg  *viper.Viper
21}
22
23func New(cfg *viper.Viper, log *zap.Logger) (*Server, error) {
24   server := &Server{
25      echo: echo.New(),
26      log:  log,
27      cfg:  cfg,
28   }
29
30   server.configure(cfg.Sub("setting"))
31
32   server.routes(
33      handler.New(),
34      middleware.New(),
35   )
36
37   return server, nil
38}
39
40func (s *Server) Start(ctx context.Context) error {
41   errorChan := make(chan error, 1)
42
43   go s.start(errorChan)
44
45   select {
46   case <-ctx.Done():
47      s.log.Info("Shutting down the server")
48      if shutdownErr := s.echo.Shutdown(ctx); shutdownErr != nil {
49         s.log.Error("Error shutting down the server", zap.Error(shutdownErr))
50         return shutdownErr
51      }
52   case err := <-errorChan:
53      s.log.Fatal("Failed to start HTTP server", zap.Error(err))
54      return err
55   }
56
57   return nil
58}
59
60func (s *Server) start(errorChan chan<- error) {
61   defer close(errorChan)
62
63   if err := s.echo.Start(
64      net.JoinHostPort(
65         s.cfg.GetString("host"),
66         s.cfg.GetString("port"),
67      ),
68   ); err != nil && !errors.Is(err, http.ErrServerClosed) {
69      errorChan <- err
70   }
71}
72
73func (s *Server) configure(cfg *viper.Viper) {
74   if cfg.IsSet("debug") {
75      s.echo.Debug = cfg.GetBool("debug")
76   }
77
78   if cfg.IsSet("hide_banner") {
79      s.echo.HideBanner = cfg.GetBool("hide_banner")
80   }
81
82   if cfg.IsSet("hide_port") {
83      s.echo.HidePort = cfg.GetBool("hide_port")
84   }
85
86   s.echo.Validator = validator.New()
87   s.echo.HTTPErrorHandler = handleErrors(s.log, cfg.GetBool("debug"))
88}
89
90func handleErrors(log *zap.Logger, debug bool) echo.HTTPErrorHandler {
91   return func(err error, c echo.Context) {
92      var (
93         code       = http.StatusInternalServerError
94         msg        string
95         errorStack any
96      )
97
98      if he, ok := err.(*echo.HTTPError); ok {
99         code = he.Code
100         msg = he.Message.(string)
101      } else {
102         msg = err.Error()
103         switch true {
104         case errors.Is(err, errors.BadRequest):
105            code = http.StatusBadRequest
106         case errors.Is(err, errors.Forbidden):
107            code = http.StatusForbidden
108         case errors.Is(err, errors.Unauthorized):
109            code = http.StatusUnauthorized
110         case errors.Is(err, errors.NotFound):
111            code = http.StatusNotFound
112         case errors.Is(err, errors.AlreadyExists):
113            code = http.StatusConflict
114         }
115
116         if debug {
117            errorStack = errors.ErrorStack(err)
118         }
119      }
120
121      if !c.Response().Committed {
122         if err != nil && code == http.StatusInternalServerError {
123            log.Error("An error occurred", zap.Error(err))
124         }
125
126         if c.Request().Method == echo.HEAD {
127            err = c.NoContent(code)
128         } else {
129            m := echo.Map{"error": msg}
130            if errorStack != nil {
131               m["errorStack"] = errorStack
132            }
133            err = c.JSON(code, m)
134         }
135      }
136   }
137}

حالا اولین هندلر خودمان را در پوشه handler، فایلی به نام hello_world.go بسازیم:

1package handler
2
3import (
4   "net/http"
5
6   "github.com/labstack/echo/v4"
7)
8
9func (h *Handler) HelloWold(c echo.Context) error {
10   return c.String(http.StatusOK, "Hello, World!")
11}

برای این هندلر، یک مسیر (route) هم در route.go اضافه می‌کنیم:

1package rest
2
3import (
4   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest/handler"
5   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest/middleware"
6)
7
8func (s *Server) routes(h *handler.Handler, m *middleware.Middleware) {
9   s.echo.GET("/hello", h.HelloWold)
10}

اجرای سرور

حالا که سرورمان آماده است، یک دستور برای اجرای آن می‌نویسیم. برای مدیریت CLI از پکیج https://github.com/spf13/cobra استفاده می‌کنیم. در ./cmd/myapp/cli فایلی به نام cli.go می‌سازیم:

1package cli
2
3import (
4   "github.com/spf13/cobra"
5   "github.com/spf13/viper"
6)
7
8var (
9   config string
10   cfg    *viper.Viper
11)
12
13var cmd = &cobra.Command{
14   Use:   "cmd",
15   Short: "ShortDescription ",
16   Long:  `Long Description`,
17   PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
18      cfg = viper.New()
19      cfg.AddConfigPath(".")
20      cfg.AutomaticEnv()
21      cfg.SetConfigFile(config)
22      return cfg.ReadInConfig()
23   },
24}
25
26func Execute() error {
27   return cmd.Execute()
28}
29
30func init() {
31   cmd.PersistentFlags().StringVarP(
32      &config, "config", "c", "config.yml",
33      "path to config file",
34   )
35}

در همان مسیر، فایل http_server.go را ایجاد و کد اجرای سرور را قرار می‌دهیم. توجه کنید که اینجا لاگر به‌صورت موقت ایجاد شده و بعداً اصلاح می‌شود:

1package cli
2
3import (
4   "os"
5   "time"
6
7   "github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest"
8   "github.com/spf13/cobra"
9   "go.uber.org/zap"
10   "go.uber.org/zap/zapcore"
11)
12
13const HttpServerCommand = "http-server"
14const VersionHttpServer = "1.0.0"
15
16var httpServer = &cobra.Command{
17   Use:     HttpServerCommand,
18   Short:   "Start http server",
19   Version: VersionHttpServer,
20   RunE: func(cmd *cobra.Command, args []string) (err error) {
21      bws := &zapcore.BufferedWriteSyncer{
22         WS:            os.Stderr,
23         Size:          512 * 1024,
24         FlushInterval: time.Minute,
25      }
26      defer bws.Stop()
27      consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
28      core := zapcore.NewCore(consoleEncoder, bws, zapcore.DebugLevel)
29      log := zap.New(core)
30
31      cnf := cfg.Sub("app.api.rest")
32
33      var server *rest.Server
34      if server, err = rest.New(cnf, log); err != nil {
35         return err
36      }
37
38      return server.Start(cmd.Context())
39   },
40}
41
42func init() {
43   cmd.AddCommand(httpServer)
44}

و در نهایت، در ./cmd/myapp/main.go که ابتدا ساختیم، کد زیر را وارد کنید:

1package main
2
3import (
4   "fmt"
5   "os"
6
7   "github.com/i4erkasov/go-ddd-cqrs/cmd/myapp/cli"
8)
9
10func main() {
11   if err := cli.Execute(); err != nil {
12      _, _ = fmt.Fprintf(os.Stderr, "Some error occurred during execute app. Error: %v\n", err)
13
14      os.Exit(2)
15   }
16}

حالا می‌توانید سرور را اجرا کنید. وارد دایرکتوری ./cmd/myapp شوید و دستور زیر را اجرا کنید (مسیر فایل کانفیگ‌تان را به جای <path to the configuration file> قرار دهید):

1go run main.go http-server -c <path to the configuration file>

برای من چنین بود:

1go run main.go http-server -c ~/go/src/github.com/i4erkasov/go-ddd-cqrs/.bin/config.dev.yaml

در کنسول باید پیام زیر را ببینید:

پیام اجرا شدن موفق سرور

سرور با موفقیت اجرا شده است. حالا می‌توانید در مرورگر به آدرس http://127.0.0.1:8088/hello بروید و پاسخ "Hello, World!" را ببینید.

نمایش پاسخ "Hello, World!"

نتیجه‌گیری و گام‌های بعدی

در این مقاله با ساختار دایرکتوری پروژه آشنا شدیم و راه‌اندازی سرور HTTP REST را شروع کردیم. این مراحل پایه‌ای مهم برای توسعه بعدی اپلیکیشن بر اساس اصول DDD و CQRS هستند. در مقاله بعدی، اضافه کردن دستورات مرتبط با migrationها، بررسی لایه application و اهمیت پوشه pkg را دنبال خواهیم کرد.
کد تمام آنچه اینجا پیاده‌سازی شده در گیت‌هاب موجود است: https://github.com/i4erkasov/go-ddd-cqrs/tree/part-1

قسمت 1.1 >>

گو

۰


نظرات


author
نویسنده مقاله: حامد فیض آبادی

کد با می متعهد است که بالاترین سطح کیفی آموزش را در اختیار شما بگذارد. هدف به اشتراک گذاشتن دانش فناوری اطلاعات و توسعه نرم افزار در بالاترین سطح ممکن برای درستیابی به جامعه ای توانمند و قدرتمند است. ما باور داریم هر کسی میتواند با استمرار در یادگیری برنامه نویسی چالش های خود و جهان پیرامون خود را بر طرف کند و به موفقیت های چشم گیر برسد. با ما در این مسیر همراه باشید. کد با می اجتماع حرفه ای برنامه نویسان ایرانی.

تمام حقوق این سایت متعلق به وبسایتcodebymeمیباشد.