پیاده سازی یک سرویس قابل تست در Golang - قسمت ۱

گو زبون قوی ایه. به صورت پیشفرض وقتی که نصبش میکنید روی سیستمتون با کتاب خانه‌های استاندارد خودش میتونید یک سیستم قوی بنویسید که پرفورمنس عالی ایی داشته باشه. نیازی به فریم ورک و .. ندارید که کدتون سریع تر و بهینه تر اجرا شه. این موضوع خیلی خوبه اما اگر از دنیای فریم ورک ها وارد Go شده باشید حتما براتون سوال هست خب چطور من کد بزنم؟ معماری نرم افزارم چطور باشه؟ چطور تست کنم و ...


کوتاه کننده لینک، مثال همیشگی

فرض کنید میخوایم یک سیستم کوتاه کننده لینک بزنیم. این سیستم ۲ تا وظیفه ساده رو میخواد انجام بده، بهش لینک بدیم و بهمون یک آدرس کوتاه شده بده، وارد آدرس کوتاه شده هم شدیم ما رو redirect کنه به آدرسی که بهش داده بودیم.

چنین سیستمی رو خیلی راحت میشه زد. میگیم کلا ۲ تا route داره، توی اون route ها دیتابیس رو صدا میزنیم و از دیتابیس اطلاعات میگیریم. اگر قرار باشه سیستم ما دقیقا کارش این باشه و هیچ وقت هیچ تغییری توش نداشته باشیم خوب همین کد رو میزنیم میره پی کارش. اما الان درسته مثال ساده ایی زدیم ولی در نظر بگیریم سیستم ما یک مونولیت بزرگی میخواد بشه بعدا که کلی آدم روش کار میکنن و قراره تکنولوژی های جدیدی بهش اضافه بشه و فیچر بدیم و ...

اینجاست که سوال اصلی مطرح میشه. معماری سیستم من چطور باشه؟ میتونیم خودمون طبق نظرمون یک معماری بزنیم و بریم جلو ببینیم جواب میده یا نه، یا از معماری های معمولی که بقیه هم باهاشون آشنا هستن مثل هگزاگونال و کلین و onion و ... استفاده کنیم.

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

معماری هگزاگونال چی میگه؟

اول ببینیم نرم‌افزاری که معمولا میزنیم چیه؟ وقتی به نرم‌افزار میگیم فلان URL رو کوتاه کن ما از طریق HTTP داریم به نرم‌افزار دستور میدیم. حالا این اتفاق میتونست توسط web socket اتفاق بیوفته! اصلا نه، ما میتونستیم یک command line داشته باشیم و یک دستور بدیم که فلان URL رو بساز. توی این حالت روشی که به نرم‌افزار دستور میدیم فرق میکنه. اما همیشه به نرم افزار یک URL میدیم و ازش یک URL کوتاه شده میخوایم. منطق و بیزینس لاجیک نرم افزار ثابته ولی اینکه چطور باهاش صحبت میکنیم فرق میکنه.

حالا از یک زاویه دیگه نگاه کنیم. دیدیم که ما داریم به سیستممون یک سری دستور میدیم اما خود سیستم هم با یک سری بازیگر های دیگه کار داره! مثلا وقتی میگیم فلان آدرس رو کوتاه کن سیستم باید آدرس جدید و آدرس قدیمی رو یک جا ذخیره کنه. حالا میتونه دیتابیس SQLایی باشه، مونگو باشه، توی فایل و ردیس.

توی دو بند بالا دیدیم سیستم ما ۲ تا وابستگی داره که باهاشون یا صدا زده میشه یا سیستم اون ها رو صدا میزنه. اما اینکه اون ها چطور انجام میشن تاثیری در هسته سیستم ما نداره. وقتی میخوایم URL بسازیم باید یک رشته تصادفی تولید کنیم، بررسی کنیم آدرسی که به ما دادن آدرس اینترنتی درستی هست یا نه و در نهایت بدیم که ذخیره بشه. اینکه چطور ذخیره میشه دیگه به هسته سیستم ما ربطی نداره.

برای اینکه به چنین چیزی برسیم میتونیم از معماری هگزاگونال استفاده کنیم

معماری هگزاگونال
معماری هگزاگونال

در این معماری ما دو تا ۶ ضلعی داریم. ۶ضلغی داخلی تعریف کننده هسته‌ی برنامه ماست.توی هسته ما کد های لاجیک ما قرار میگیرد. بیزینس لاجیک ما از طریق یک سری port (درگاه) با بیرون ارتباط برقرار میکنند. ما دو نوع port داریم:

  • پورت های Driving: از طریق این درگاه ها اپلیکیشن ما صدا زده شده و عملیات خاصی که میخواهیم انجام میشود. به این‌ها پورت های ورودی هم میگیم چون اولین پورتی هستن که دنیای خارج صدا میزنه.
  • پورت‌های Driven: این درگاه ها توسط اپلیکیشن صدا زده میشوند (مانند دیتابیس، سرویس SMS، کش و ..)

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

پروژه Go رو بسازیم

من یک پروژه Go ایی با go modules ساختم که میخوایم سرویس کوتاه کننده لینک رو توش پیاده سازی کنیم. اول از همه یک پوشه به اسم internal میسازم که کد های مربوط به سرویسمون رو اونجا بیاریم.

اول ببینیم چه دیتاهایی قراره داشته باشیم. توی پوشه internal یک پوشه به اسم models میسازیم که پکیج نگه‌داری مدل هامون رو داشته باشیم. توی پوشه models هم اولین مدلمون یعنی URL رو میسازیم:

پکیج models رو برای ساخت مدل هامون استفاده میکنیم
پکیج models رو برای ساخت مدل هامون استفاده میکنیم

میدونیم که قراره مدل URL ما یک کلید کوتاه مختص به خودش داشته باشه و همچنین یک فیلد redirect هم میخوایم که مشخص کنه کجا میخوایم کاربر رو redirect کنیم:

type URL struct {
   Key       string
   Redirect  string
   CreatedAt int64
}

حالا port هامون رو مشخص میکنیم. توی پوشه internal یک پوشه برای پکیج ports میسازیم. همونطوری که گفتیم دو نوع پورت داریم که توی دو فایل مختلف به نام های incoming و outgoing تعریفشون میکنیم (اسم ها برای من اینطور واضح ترن)

حالا با استفاده از یک اینترفیس تعریف میکنیم که پورت های outgoing ما میخوان چطور باشن (بعدا آداپتور ها باید این اینترفیس ها رو پیاده سازی کنند، مثلا دیتابیس SQLایی)

type URLRepository interface {
   Save(ctx context.Context, url *models.URL) error
   Find(ctx context.Context, code string) (models.URL, error)
}

و همینکار رو هم برای پورت های incoming هم میکنیم (پورت هایی که صدازده میشن. مثلا rest ما صدا میزنه که فلان url رو بساز)

type URLService interface {
   Save(ctx context.Context, url *models.URL) error
   Find(ctx context.Context, code string) (models.URL, error)
}

چون مثال ما ساده هست service ما خیلی شبیه repository میشه. توی مرحله اول کار service اینه یک ولیدیشن ساده روی دیتاها انجام بده و یک کلید تصادفی به URL بده و در نهایت برای repository بفرسته که ذخیره بشن. اما یکم جلوتر cache رو هم اضافه میکنیم که می بینیم کارمون چقدر راحت تر میشه.

حالا پورت ها رو ساختیم. چیکار کنیم؟ میدونیم همه چیزی که هسته ما نیاز داره رو داریم. شروع میکنیم به نوشتن هسته. اولین چیزی که باید پیاده سازی کنیم سرویس URLهست. برای این کار توی internal یک فایل به اسم url.go میسازم:

type urlService struct {
   urlRepository ports.URLRepository
}

func (u urlService) Save(ctx context.Context, url *models.URL) error {
   url.CreatedAt = time.Now().Unix()
   url.Key = shortid.MustGenerate()
   return u.urlRepository.Save(ctx, url)
}
func (u urlService) Find(ctx context.Context, code string) (models.URL, error) {
   return u.urlRepository.Find(ctx, code)
}

کاری که ما کردیم اینه اطلاعاتی که گرفتیم رو یکم ویرایش کردیم و برای مدلمون زمان ساخت رو براساس زمان timestamp فعلی ست کردیم و یک کلید رندوم با استفاده از پکیج shortid ساختیم و دادیم به دیتابیس و اطلاعاتی که دیتابیس به ما میده رو مستقیم داریم بر میگردونیم. اما مثلا اگر به ما URL اشتباهی دادن چه اتفاقی میوفته؟ من میخوام یک validation به لایه service مون اضافه کنم. برای این کار اول مدل URLرو اینطور عوض میکنم که از پکیج go-playground/validator بتونم استفاده کنم:

type URL struct {
   Key       string
   Redirect  string `validate:&quotrequired,url&quot`
   CreatedAt int64
}

و توی سرویسمون هم اینطور ورودی رو validate میکنیم.

import (
   &quotcontext&quot
   &quoterrors&quot
   &quotgithub.com/mhrlife/kootah/internal/models&quot
   &quotgithub.com/mhrlife/kootah/internal/ports&quot
   errs &quotgithub.com/pkg/errors&quot
   &quotgopkg.in/go-playground/validator.v9&quot
)

var (
   ErrInputValidationFailed = errors.New(&quotinput validation failed&quot)
)

type urlService struct {
   urlRepository ports.URLRepository
   validator     *validator.Validate
}

func NewUrlService(urlRespository ports.URLRepository) ports.URLService {
   return &urlService{
      urlRepository: urlRespository,
      validator:     validator.New(),
   }
}

func (u urlService) Save(ctx context.Context, url *models.URL) error {
   if err := u.validator.Struct(url); err != nil {
      return errs.Wrap(ErrInputValidationFailed, err.Error())
   }
   url.CreatedAt = time.Now().Unix()
   url.Key = shortid.MustGenerate()
   return u.urlRepository.Save(ctx, url)
}

func (u urlService) Find(ctx context.Context, code string) (models.URL, error) {
   return u.urlRepository.Find(ctx, code)
}

من یه سری کار کردم. اول از همه یک ارور جدید تعریف کردم به اسم ErrInputValidationFailed که با استفاده از اون اگر ورودی هام مشکل داشتن بتونم ارور برگردونم. برای اینکه بتونم validation هم انجام بدم باید یک validator میساختم. همیشه برای اینکه یک تایپ خاص رو بسازیم معمولا تابع NewTypeName میسازیم که توش هم مقدار دهی اولیه انجام بدیم و در نهایت آبجکتی که ساختیم رو خروجی بدیم. اگر توجه کنید نوع خروجی ما اینترفیس سرویسی هست که قبلا ساخته بودیم. در نهایت هم قبل از اینکه بخوایم چیزی ذخیره کنیم کار validation رو انجام میدیم.

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

نوشتن تست و اجرای آن

برای اینکه متوجه بشیم لاجیکی که نوشتیم درست کار میکنه یا نه باید تستش کنیم. اما الان ما آداپتوری نداریم. دیتابیسی نداریم. postman ایی نداریم که درخواست HTTP بزنه. چطور تست کنیم؟

معماری هگزاگونال به ما این امکان رو میده بیزینس لاجیک رو بدون نیاز به هیچگونه آداپتوری (دیتابیس، REST و...) تست کنیم. چون اصلا هدف کار ما این بود. ما کدی زدیم که لایه لاجیک رو از همه چیز جدا کنیم.

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

برای مثال اگر میخوایم ببینیم لاجیک ما درست کار میکنه برای تابع Save که خیلی سادست یک تست مینویسیم که URL اشتباه باشه و یک تست که URL درست باشه (تنها لاجیک ما در این مثال ساده همین بوده). بیاید همین الان همین کار رو بکنیم. برای نوشتن ماک و تست کردن از کتابخانه mockery و testify استفاده میکنیم. با استفاده از کامند زیر برای تک تک اینترفیس هامون یک فایل mock میسازیم:

mockery --all

این ابزار ماک های ما رو در یک پوشه به نام mocks قرار میده و بعدا از این پکیج میتونیم استفاده کنیم. برای نوشتن تست های urlمون توی پوشه internal کنار فایل url یک فایل به نام url_test.go میسازم.

اولین تستی که میخوایم بنویسیم اینه که یک URL درست بدیم و انتظار داریم Save ریپازیتوری Url صدا زده بشه و یک مقدار random برای کلید URL ست بشه. برای این کار اینطور تستی مینویسیم:

func TestUrlService_SaveSuccessful(t *testing.T) {
   urlRepMock := &mocks.URLRepository{}
   logicUrl := NewUrlService(urlRepMock)
   ctx := context.Background()

   urlRepMock.On(&quotSave&quot, ctx, mock.AnythingOfType(&quot*models.URL&quot)).Return(nil)

   url := models.URL{Redirect: &quothttps://google.com&quot}
   err := logicUrl.Save(ctx, &url)

   assert.Equal(t, nil, errs.Cause(err))
   assert.NotEqual(t, &quot&quot, url.Key)

   urlRepMock.AssertExpectations(t)
}

برای اجرای تست:

go test ./...

اول اومدیم یک logic با استفاده از mockمون ساختیم. mockبه ما این قابلیت رو میده بهش بگیم انتظار چه ورودی هایی داشته باشه و هر کدوم رو گرفت چه رفتاری نشون بده. برای مثال گفتیم اگر Save صدا زده شد ورودی اولت باید context ایی باشه که اول ساختیم و ورودی دوم از تایپ models.URL و وقتی این رو گرفتی nil برگردون (یعنی ارور نخوردیم)

حالا وقتی logicUrl.Save صدا زده میشه توی لاجیک متد Save از urlRepository هم صدا زده میشه که طبق mockمون عمل میکنه. در آخر برای اینکه متوجه بشیم تست ما درست انجام شده چک میکنیم که ارورمون حتما nil باشه و اینکه توی تابع برای Key یک مقدار تصادفی ست شده باشه. در نهایت هم با AssertExpectations هم به Mock میگیم چک کن تمام انتظاراتی که داشته برآورده شده اند یا نه.

حالا یک تست جدید مینویسیم که انتظار داریم ارور دریافت کنیم چون میخوایم جای urlیک عبارت اشتباه بدیم:

func TestUrlService_SaveErrValidation(t *testing.T) {
   urlRepMock := &mocks.URLRepository{}
   logicUrl := NewUrlService(urlRepMock)
   ctx := context.Background()

   url := models.URL{Redirect: &quotrandom text&quot}
   err := logicUrl.Save(ctx, &url)

   assert.Equal(t, ErrInputValidationFailed, errs.Cause(err))

   urlRepMock.AssertExpectations(t)
}

توی این تست صرفا چک کردیم که حتما error بگیریم و ارورمون ErrInputValidationFailed باشه.

جمع بندی

ما با استفاده از این معماری تونستیم بیزینس لاجیکمون رو بدون بالااوردن دیتابیس و نوشتن یک سرویس RESTو... تست کنیم. اینطور باعث میشه سرعت کارمون هم بیشتر بره بالا هم بخاطر تست هایی که مینویسیم کدمون در آینده قابل اعتماد تر باشه.

در مقاله بعدی آداپتور ها رو مینویسیم و یک لایه کش هم به سرویسمون اضافه میکنیم.