Skip to content

jamalkaksouri/uber-go-guide-ir

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 

Repository files navigation

راهنمای سبک و استایل برنامه‌نویسی شرکت اوبر (Uber) در گولنگ

تغییرات

این مخزن به موازات نسخه اصلی آن به صورت بلادرنگ آپدیت خواهد شد. همچنین می‌توانید لیست کامل تغییرات را در فایل CHANGELOG.md مشاهده کنید.

نسخه

Note

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

فهرست مطالب

مقدمه

استایل‌ها، قراردادهایی هستند که کد ما را کنترل می‌کنند. شاید کلمه یا اصطلاح استایل درست نباشد، زیرا این قوانین خیلی فراتر از فقط قالب‌بندی فایل منبع هستند - gofmt این کار را برای ما انجام می‌دهد.

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

این راهنما در اصل توسط Prashant Varanasi و Simon Newton به عنوان راهی برای برای آموزش سریع همکاران به استفاده از Go ایجاد شده است. در طول سال‌ها، بر اساس بازخوردهای دیگران، این راهنما تصحیح و به‌روزرسانی شده است.

این راهنما اصول و قوانین معمولی در نوشتن کد Go در Uber را شامل می‌شود. بسیاری از این موارد، رهنمودهای عمومی برای Go هستند، در حالی که برخی از آنها از منابع خارجی نشات می گیرند:

  1. Effective Go
  2. Go Common Mistakes
  3. Go Code Review Comments

هدف ما این است که نمونه‌ کدهای ما برای آخرین نسخه‌های اخیر منتشر شده Go releases تنظیم شوند.

همه کدها هنگام اجرا با استفاده از golint و go vet باید بدون خطا باشند. ما پیشنهاد می‌کنیم ویرایشگر خود را به‌صورت زیر تنظیم کنید:

  • Run goimports on save
  • Run golint and go vet to check for errors

می‌توانید اطلاعات مربوط به پشتیبانی ویرایشگر برای ابزارهای Go را در اینجا پیدا کنید: https://github.com/golang/go/wiki/IDEsAndTextEditorPlugins

راهنماها

ارجاع به رابط ها (Pointers to Interfaces)

تقریباً هرگز نیازی به داشتن یک اشاره‌گر (pointer) به یک رابط (interface) ندارید. شما باید رابط‌ها را به عنوان مقادیر(passing interfaces as values) ارسال کنید - دیتاهای زیرین (underlying data) می‌توانند اشاره‌گر باشند.

یک رابط (interface) دارای دو فیلد است:

  1. یک اشاره‌گر (pointer) به برخی از اطلاعات یک نوع خاص که شما میتوانید آن را به عنوان یک "type" در نظر بگیرید.
  2. اشاره‌گر داده. اگر داده ذخیره شده یک اشاره‌گر باشد، به صورت مستقیم ذخیره می‌شود. اگر داده ذخیره شده یک مقدار باشد، آنگاه یک اشاره‌گر به مقدار ذخیره شده می‌شود.

اگر می‌خواهید متدهای رابط(interface)، تغییراتی روی داده زیرین اعمال کنند، باید از یک اشاره‌گر استفاده کنید.

انطباق پذیری رابط ها

در صورت لزوم، مطابقت رابط را در زمان کامپایل بررسی کنید. این شامل:

  • تایپ‌های Exported که برای پیاده سازی رابط‌های خاص به عنوان بخشی از قرارداد API مورد نیاز هستند
  • تایپ‌های Exported یا unexported که بخشی از مجموعه‌ای از تایپ‌ها هستند که همگی یک رابط مشابهی را پیاده‌سازی می‌کنند
  • سایر موارد دیگری که در آن نقض یک رابط باعث نقض قراردادها می‌شود
بدخوب
type Handler struct {
  // ...
}



func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}
type Handler struct {
  // ...
}

var _ http.Handler = (*Handler)(nil)

func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

دستور var _ http.Handler = (*Handler)(nil) در صورت عدم تطبیق *Handler با رابط http.Handler، کامپایل نخواهد شد.

سمت راست تخصیص داده شده عبارت بالا باید مقدارصفر (zero value) نوع ادعا شده باشد. این مقدار برای انواع اشاره‌گر (مانند Handler*)، آرایه‌ها و نقشه‌ها nil و برای انواع ساختاری (struct) یک ساختار خالی (empty struct) است.

type LogHandler struct {
  h   http.Handler
  log *zap.Logger
}

var _ http.Handler = LogHandler{}

func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

گیرنده ها و رابط ها (Recievers and Interfaces)

متدهایی که دارای گیرنده‌های مقدار (value receivers) هستند، می‌توانند روی اشاره‌گرها و همچنین مقادیر فراخوانی شوند. متدهایی که دارای گیرنده‌های اشاره‌گر (pointer receivers) هستند، فقط می‌توانند روی اشاره‌گرها یا مقادیر آدرس‌پذیر addressable values فراخوانی شوند.

برای مثال,

type S struct {
  data string
}

func (s S) Read() string {
  return s.data
}

func (s *S) Write(str string) {
  s.data = str
}

// ما می‌توانیم متد Read را بر روی مقادیری که در نقشه‌ها ذخیره شده‌اند، فراخوانی کنیم
// زیرا متد Read دارای گیرنده مقدار (value receiver) است و این نیازی به قابل دسترس بودن مقدار ندارد
sVals := map[int]S{1: {"A"}}


sVals[1].Read()

// ما نمی‌توانیم متد Write را بر روی مقادیری که در نقشه‌ها ذخیره شده‌اند، فراخوانی کنیم
// زیرا متد Write دارای گیرنده اشاره‌گر (pointer receiver) است
// و امکان دسترسی به مقادیری که در نقشه ذخیره شده است، با اشاره‌گر وجود ندارد
//
//  sVals[1].Write("test")

sPtrs := map[int]*S{1: {"A"}}

// اگر نقشه اشاره‌گرها را در خود ذخیره کند، شما می‌توانید هم متد Read و هم متد Write را فراخوانی کنید،
// زیرا اشاره‌گرها به طور طبیعی آدرس‌پذیر هستند
sPtrs[1].Read()
sPtrs[1].Write("test")

به طور مشابه، یک رابط می تواند توسط یک اشاره‌گر satisfy شود، حتی اگر متد دارای یک گیرنده مقدار (value receiver) باشد.

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}

var i F
i = s1Val
i = s1Ptr
i = s2Ptr

// موارد زیر کامپایل نمی شوند، زیرا s2Val یک مقدار است و هیچ گیرنده مقداری برای f وجود ندارد.
//   i = s2Val

منبع Effective Go توضیح بسیار خوبی در مورد Pointers vs. Values دارد.

مقدار صفر (zero-value) Mutexها معتبر هستند

مقدار صفر sync.Mutex و sync.RWMutex معتبر است، بنابراین تقریباً هرگز نیازی به اشاره‌گر به mutex ندارید.

بدخوب
mu := new(sync.Mutex)
mu.Lock()
var mu sync.Mutex
mu.Lock()

اگر از اشاره‌گر به یک ساختار (struct) استفاده می‌کنید، mutex باید به عنوان یک فیلد غیر اشاره‌گری درون آن قرار گیرد. حتی اگر ساختار (struct) به صورت (non-exported) استفاده شود، نباید mutex را به طور مستقیم درون ساختار جاسازی (embedded) کنید.

بدخوب
type SMap struct {
  sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.Lock()
  defer m.Unlock()

  return m.data[k]
}
type SMap struct {
  mu sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

فیلد Mutex و متدهای Lock and Unlock ناخواسته بخشی از API صادر شده SMap هستند.

میوتکس (mutex) و متدهای آن جزئیات پیاده‌سازی SMap هستند که از تماس‌گیرندگان آن مخفی می‌مانند.

کپی کردن بخش های مشخص از Sliceها و Mapها

برش‌ها (slices) و نقشه‌ها (maps) شامل اشاره‌گرهایی به داده زیرین خود هستند، بنابراین در مواردی که نیاز به کپی آنها دارید، مراقب باشید.

دریافت Slices و Maps

به خاطر داشته باشید که اگر شما یک ارجاع به Map یا Slice که به عنوان ورودی دریافت کرده‌اید نگه دارید، کاربران ممکن است تغییراتی در آن‌ها ایجاد کنند.

بد خوب
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = trips
}

trips := ...
d1.SetTrips(trips)

// آیا شما منظورتان از تغییر d1.trips بود؟
trips[0] = ...
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

// ما می‌توانیم trips[0] را تغییر دهیم بدون اینکه تأثیری روی d1.trips داشته باشه.
trips[0] = ...

برگرداندن Slices و Maps

به طور مشابه، مراقب تغییراتی باشید که کاربران در Mapها یا Sliceها اعمال می‌کنند و وضعیت داخلی آنها را فاش می‌کنند.

بدخوب
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

// "Snapshot" وضعیت فعلی را برمی‌گرداند
func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

// "Snapshot" دیگر توسط mutex محافظت نمی‌شود
// بنابراین هر دسترسی به "Snapshot" منجر به احتمال تداخل داده‌ها (data races) می‌شود.
snapshot := stats.Snapshot()
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  result := make(map[string]int, len(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

// "Snapshot" اینجا یک کپی است
snapshot := stats.Snapshot()

به تعویق انداختن (Defer) پاکسازی منابع

از defer برای پاکسازی منابعی مانند فایل‌ها و قفل‌ها استفاده کنید.

بدخوب
p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount

// به دلیل وجود return های متعدد
// ممکن است آزاد کردن قفل‌ها را فراموش کنید
p.Lock()
defer p.Unlock()

if p.count < 10 {
  return p.count
}

p.count++
return p.count

// خیلی خواناتر

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

اندازه کانال (Channel) یک یا هیچ است

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

بدخوب
// باید برای هر کسی کافی باشد!
c := make(chan int, 64)
// اندازه یک
c := make(chan int, 1) // یا
// کانال بدون بافر، اندازه صفر
c := make(chan int)

ثابت های نام گذاری شده (Enums) را از یک شماره گذاری کنید

روش استاندارد برای معرفی تعداد محدودی (enumeration) در Go، اعلام یک نوع سفارشی (custom type) و یک گروه const با iota است. از آنجا که متغیرها معمولاً مقدار پیش‌فرض 0 دارند، بهتر است enums خود را با یک مقدار غیرصفر شروع کنید.

بدخوب
type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

// Add=0, Subtract=1, Multiply=2
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

// Add=1, Subtract=2, Multiply=3

مواردی وجود دارد که استفاده از مقدار صفر منطقی است، برای مثال زمانی که حالت صفر رفتار پیش‌فرض مطلوب است.

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

// LogToStdout=0, LogToFile=1, LogToRemote=2

استفاده از "Time" برای مدیریت زمان

زمان پیچیده است. مفروضات نادرستی که اغلب در مورد زمان انجام می‌شود شامل موارد زیر است.

  1. یک روز 24 ساعت دارد
  2. یک ساعت 60 دقیقه دارد
  3. یک هفته 7 روز دارد
  4. یک سال 365 روز دارد
  5. و موارد دیگر

به عنوان مثال، 1 به این معنی است که افزودن 24 ساعت به یک لحظه از زمان، همیشه یک روز تقویمی جدید ایجاد نمی‌کند.

بنابراین، هنگام برخورد با زمان، همیشه از پکیج "زمان" استفاده کنید زیرا به مقابله با این فرضیات نادرست به شیوه ای مطمئن‌تر و دقیق‌تر کمک می‌کند.

از time.Time برای نمایش لحظات زمانی استفاده کنید.

هنگام کار با لحظات زمانی از نوع time.Time و متدهای مربوط به time.Time برای مقایسه، افزودن، یا کاستن زمان استفاده کنید.

بدخوب
func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}
func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

از time.Duration برای بازه‌های زمانی استفاده کنید.

هنگام کار با بازه‌های زمانی از نوع time.Duration استفاده کنید.

بدخوب
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}

poll(10) // ثانیه بود یا میلی‌ثانیه؟
func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}

poll(10*time.Second)

به مثال افزودن 24 ساعت به یک لحظه زمانی برگردیم، روشی که برای اضافه کردن زمان استفاده می‌کنیم به هدف ما بستگی دارد. اگر بخواهیم همان نقطه زمانی را در روز بعدی تقویم (روز بعد از روز جاری) داشته باشیم، باید از Time.AddDate استفاده کنیم. اما اگر بخواهیم یک لحظه زمانی داشته باشیم که تضمین می‌کند 24 ساعت بعد از زمان قبلی باشد، باید از متد Time.Add استفاده کنیم.

newDay := t.AddDate(0 /* سالها */, 0 /* ماهها */, 1 /* روزها */)
maybeNewDay := t.Add(24 * time.Hour)

در تعامل با سیستم‌های خارجی، از نوع‌های time.Time و time.Duration استفاده کنید.

در صورت امکان در تعاملات با سیستم‌های خارجی، از نوع‌های time.Duration و time.Time استفاده کنید. به عنوان مثال:

  • در پردازش پارامترهای خط فرمان (Command-line flags)، کتابخانه flag توانایی پشتیبانی از نوع time.Duration را از طریق تابع time.ParseDuration دارد.
  • در پردازش داده‌های JSON، کتابخانه encoding/json از تبدیل نوع time.Time به یک رشته RFC 3339 به وسیله تابع UnmarshalJSON method پشتیبانی می‌کند.
  • در پردازش داده‌های SQL، کتابخانه database/sql توانایی تبدیل ستون‌های DATETIME یا TIMESTAMP به نوع time.Time و برعکس را دارد، اگر درایور پایگاه داده مربوط این پشتیبانی را داشته باشد.
  • در پردازش داده‌های YAML، کتابخانه gopkg.in/yaml.v2 از نوع time.Time به عنوان یک رشته RFC 3339 و از تابع time.ParseDuration برای پشتیبانی از نوع time.Duration استفاده می‌کند.

زمانی که در تعامل با سیستم‌های خارجی امکان استفاده از نوع time.Duration وجود ندارد، می‌توانید از انواع داده مانند int یا float64 استفاده کنید و واحد زمان را در نام فیلد درج کنید.

برای مثال، از آنجایی که encoding/json از time.Duration پشتیبانی نمی کند، واحد زمان در نام فیلد گنجانده شده است.

بدخوب
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}
// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

زمانی که در تعامل با سیستم‌های خارجی امکان استفاده از نوع time.Time وجود نداشته باشد، از نوع string استفاده کنید و زمان‌ها را با فرمت مشخص شده در RFC 3339 تعریف کنید مگر اینکه روش جایگزین دیگری داشته باشید. این فرمت به طور پیش‌فرض توسط تابع Time.UnmarshalText استفاده می‌شود و از طریق time.RFC3339 در توابع Time.Format و time.Parse نیز در دسترس هستند.

اگرچه این معمولاً مشکلی ایجاد نمی‌کند، به یاد داشته باشید که پکیج "time" از Go قادر به parse کردن زمان‌هایی با ثانیه‌های کبیسه (leap seconds) را ندارد (8728) و همچنین در محاسبات، ثانیه‌های کبیسه را در نظر نمی‌گیرد (15190). اگر دو لحظه زمانی را مقایسه کنید، اختلاف زمانی شامل ثانیه‌های کبیسه که ممکن است بین این دو لحظه رخ داده باشد، نخواهد بود.

خطاها (Errors)

انواع خطاها

گزینه‌های کمی برای اعلام خطا وجود دارد. قبل از انتخاب گزینه‌ای که مناسب‌ترین مورد استفاده شما است، موارد زیر را در نظر بگیرید:

  • آیا تماس گیرنده (caller) باید خطا را مطابقت دهد تا بتواند آن را مدیریت کند؟ اگر چنین است، باید از توابع errors.Is یا errors.As با اعلان متغیرهای خطای سطح بالا یا انواع سفارشی پشتیبانی کنیم.
  • آیا پیام خطا یک رشته ثابت است یا یک رشته پویا است که به اطلاعات متنی نیاز دارد؟ در مورد رشته‌های استاتیک می‌توانیم از errors.New استفاده کنیم، اما برای دومی باید از fmt.Errorf یا یک نوع خطای سفارشی استفاده کنیم.
  • آیا ما خطای جدیدی را منتشر می‌کنیم که توسط توابع پایین دست بازگردانده شده است؟ اگر چنین است، بخش section on error wrapping را ببینید.
خطا مطابقت دارد؟ پیغام خطا راهنمایی
No static errors.New
No dynamic fmt.Errorf
Yes static top-level var with errors.New
Yes dynamic custom error type

به عنوان مثال، از errors.New برای نمایش خطاها با رشته ایستا (static string) استفاده کنید. اگر تماس گیرنده (caller) نیاز به مطابقت و رسیدگی به این خطا دارد، این خطا را به عنوان یک متغیر برای پشتیبانی از تطبیق آن با errors.Is صادر کنید.

بدون تطابق خطاتطابق خطا
// package foo

func Open() error {
  return errors.New("could not open")
}

// package bar

if err := foo.Open(); err != nil {
  // Can't handle the error.
  panic("unknown error")
}
// package foo

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

// package bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // handle the error
  } else {
    panic("unknown error")
  }
}

برای خطای با رشته پویا (dynamic string)، اگر تماس گیرنده نیازی به تطبیق آن نداشته باشد، از fmt.Errorf و اگر تماس گیرنده نیاز به تطبیق آن داشته باشد، از یک خطای سفارشی استفاده کنید.

بدون تطابق خطاتطابق خطا
// package foo

func Open(file string) error {
  return fmt.Errorf("file %q not found", file)
}

// package bar

if err := foo.Open("testfile.txt"); err != nil {
  // Can't handle the error.
  panic("unknown error")
}
// package foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("file %q not found", e.File)
}

func Open(file string) error {
  return &NotFoundError{File: file}
}


// package bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // handle the error
  } else {
    panic("unknown error")
  }
}

توجه داشته باشید که اگر متغیرها یا انواع خطا را از یک پکیج (package) صادر کنید، آنها بخشی از API عمومی پکیج خواهند شد.

بسته بندی خطا (Error Wrapping)

در صورت عدم موفقیت یک فراخوانی (call)، سه گزینه اصلی برای انتشار خطا وجود دارد:

  • خطای اصلی را همانطور که هست برگردانید
  • زمینه (context) را با fmt.Errorf و فعل %w اضافه کنید
  • زمینه (context) را با fmt.Errorf و فعل %v اضافه کنید

اگر زمینه (context) اضافی برای افزودن وجود ندارد، خطای اصلی را همانطور که هست برگردانید. این، نوع خطا و پیام اصلی را حفظ می‌کند و برای مواردی که پیام خطای اصلی اطلاعات کافی برای ردیابی اینکه خطا از کجا آمده است، مناسب است.

در غیر این صورت، در صورت امکان، زمینه (context) را به پیام خطا اضافه کنید تا به جای دریافت خطاهای مبهم مانند "اتصال رد شد" ("connection refused")، خطاهای مفیدتری مانند "تماس با سرویس foo: اتصال رد شد" ("call service foo: connection refused") دریافت کنید.

از fmt.Errorf برای افزودن زمینه (context) به خطاهای خود استفاده کنید، بسته به اینکه تماس گیرنده بتواند علت اصلی را مطابقت داده و استخراج کند، بین %w یا %v افعال را انتخاب کنید.

  • اگر تماس‌گیرنده (caller) لازم است که به خطای اصلی دسترسی داشته باشه، از %w استفاده کنید. این یک پیش فرض خوب برای اکثر خطاهای بسته بندی است، اما توجه داشته باشید که تماس گیرندگان ممکن است به این رفتار تکیه کنند. بنابراین برای مواردی که خطای wrapping یک var یا نوع شناخته شده است، آن را مستند کنید و آن را به عنوان بخشی از قرارداد تابع آزمایش کنید.
  • از %v برای مبهم کردن خطای اصلی استفاده کنید. تماس‌گیرنده نمی‌تواند با آن مطابقت کند، اما در صورت نیاز می‌توانید در آینده به %w تغییر دهید.

هنگام اضافه کردن توضیحات به خطاهای برگشتی، با اجتناب از عباراتی مانند "failed to"، که لایه به لایه جمع می‌شود، (منظور اینکه هنگامی که یک خطا از سطح پایین‌تری به سطح بالاتری در سلسله‌مراتب کد حرکت می‌کند، تعداد خطاها و اطلاعات اضافی که به آن افزوده می‌شوند، افزایش می‌یابد و سنگین‌تر می‌شود) متن context را مختصر نگه دارید:

بدخوب
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err)
}
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %w", err)
}
failed to x: failed to y: failed to create new store: the error
x: y: new store: the error

با این حال، هنگامی که خطا به سیستم دیگری ارسال می‌شود، باید مشخص باشد که پیام یک خطا است (به عنوان مثال یک برچسب err یا پیشوند "ناموفق" در لاگ‌ها).

همچنین ببینید: فقط خطاها را بررسی نکنید، آنها را با ظرافت مدیریت کنید.

نام گذاری خطا

برای مقادیر خطا که به عنوان متغیرهای سراسری ذخیره می‌شوند بسته به اینکه آیا آنها صادر شده (exported) هستند یا خیر، از پیشوند Err یا err استفاده کنید. این راهنما جایگزین قاعده از پیشوند "_" برای متغیرهای خصوصی (Unexported) استفاده کنید است.

var (
  // در زیر، دو خطای زیر به صورت صادرشده (exported) است تا کاربران این بسته بتوانند آنها را با استفاده از `errors.Is` مطابقت دهند.

  ErrBrokenLink = errors.New("link is broken")
  ErrCouldNotOpen = errors.New("could not open")

   // این خطا صادر نمی شود زیرا نمی‌خواهیم بخشی از API عمومی ما باشد. ممکن است همچنان از آن در داخل یک بسته با اشکال استفاده کنیم.

  errNotFound = errors.New("not found")
)

برای نوع‌های سفارشی خطا، از پسوند Error استفاده کنید.

// به همین ترتیب، این خطا صادر می شود تا کاربران این بسته بتوانند آن را با errors.As مطابقت دهند.

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("file %q not found", e.File)
}

// و این خطا صادر نمی شود زیرا ما نمی خواهیم آن را بخشی از API عمومی کنیم.
// ما هنوز هم امکان استفاده از آن را داخل پکیج با errors.As داریم.

type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("resolve %q", e.Path)
}

مدیریت یکباره خطاها

وقتی فراخواننده یک خطا از فراخواننده دیگری دریافت می‌کند، بسته به اطلاعاتی که در مورد خطا دارد، می‌تواند آن را به روش‌های مختلفی اداره کند.

این شامل موارد زیر است اما محدود به این موارد نیستند:

  • اگر قرارداد فراخواننده خطاهای مشخصی تعریف کرده باشد، می‌توان با استفاده از errors.Is یا errors.As تطابق خطا را انجام داد و با توجه به اطلاعات موجود، درخواست‌ها را به صورت متفاوت اداره کرد.
  • اگر خطا قابل بازیابی باشد، خطا را ثبت کرده و سپس به تدریج به حالت نرمال بازگردید.
  • اگر خطا وضعیت شکست مرتبط با دامنه خاصی را نمایان می‌کند، یک خطای دقیقاً تعریف شده را بازگردانید.
  • خطا را بازگردانید، ساده (verbatim) یا به صورت پیچیده wrapped، به توجه به شرایط.

صرف نظر از نحوه برخورد تماس گیرنده با خطاها، معمولاً باید هر خطا را فقط یک بار مدیریت کند. به عنوان مثال، تماس گیرنده نباید خطا را ثبت کند و سپس آن را برگرداند، زیرا تماس گیرنده آن نیز ممکن است خطا را مدیریت کند.

به عنوان مثال موارد زیر را در نظر بگیرید:

توضیحاتکد

بد: خطا را ثبت کنید و آن را برگردانید

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

u, err := getUser(id)
if err != nil {
  // BAD: See description
  log.Printf("Could not get user %q: %v", id, err)
  return err
}

خوب: خطا را Wrap کنید و برگردانید.

تماس گیرندگان بالاتر از پشته، خطا را کنترل خواهند کرد. استفاده از %w تضمین می‌کند که می‌توانند خطا را با errors.Is یا errors.As مطابقت دهند.

u, err := getUser(id)
if err != nil {
  return fmt.Errorf("get user %q: %w", id, err)
}

خوب: ابتدا خطا را ثبت کنید (لاگ کنید) و سپس به آرامی و به صورت کنترل شده به وضعیت عادی یا نرمال خود بازگردید

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

if err := emitMetrics(); err != nil {
  // Failure to write metrics should not
  // break the application.
  log.Printf("Could not emit metrics: %v", err)
}

خوب: ابتدا خطا را تشخیص دهید (تطابق دهید) و سپس به آرامی و به صورت کنترل شده به وضعیت عادی یا نرمال خود بازگردید

اگر فراخواننده (caller) در قرارداد خود یک خطای خاص تعریف کرده باشد و خرابی قابل بازیابی باشد، در مورد آن خطا تطابق (match) کنید و به صورت کنترل شده آن را به حالت عادی بازگردانید. برای موارد دیگر، خطا را پوشش دهید (wrap) و آن را بازگردانید.

سایر خطاها توسط تماس گیرندگان بالاتر در پشته رسیدگی می‌شود.

tz, err := getUserTimeZone(id)
if err != nil {
  if errors.Is(err, ErrUserNotFound) {
    // User doesn't exist. Use UTC.
    tz = time.UTC
  } else {
    return fmt.Errorf("get user %q: %w", id, err)
  }
}

مدیریت نوع ادعای (Type Assertion) شکست ها

مقدار برگشتی بدست آمده از type assertion روی یک تایپ نادرست panic خواهد شد. بنابراین همیشه از اصطلاح "comma ok" استفاده کنید.

بدخوب
t := i.(string)
t, ok := i.(string)
if !ok {
  // به خوبی خطا را مدیریت کنید
}

از ایجاد Panic جلوگیری کنید (Don't Panic)

کدهایی که در محیط تولید (Production) اجرا می‌شوند، باید از وقوع (Panic) جلوگیری کنند. panicها عامل اصلی ایجاد شکست‌های متوالی(آبشاری) هستند. اگر خطایی رخ دهد، تابع باید یک خطای مناسب را برگردانده و به فراخواننده (caller) اجازه دهد تا تصمیم بگیرد که چگونه با آن برخورد کند.

بدخوب
func run(args []string) {
  if len(args) == 0 {
    panic("an argument is required")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}
func run(args []string) error {
  if len(args) == 0 {
    return errors.New("an argument is required")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

استفاده از panic/recover به عنوان یک استراتژی مدیریت خطا مناسب نمی‌باشد. یک برنامه فقط زمانی باید panic کند که چیزی غیرقابل بازیابی اتفاق بیفتد (مثلاً nil dereference). یک استثنا، مقداردهی اولیه برنامه است: شرایط نامطلوبی که باعث می شود برنامه در هنگام شروع به کار متوقف شود، ممکن است باعث panic شود.

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

حتی در تست‌ها، t.Fatal یا t.FailNow را به panics ترجیح دهید تا مطمئن شوید که آزمون به‌عنوان ناموفق علامت‌گذاری شده است.

بدخوب
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("failed to set up test")
}
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("failed to set up test")
}

از پکیج go.uber.org/atomic استفاده کنید

از عملیات اتمی بسته sync/atomic برای کار بر روی انواع اولیه (int32, int64 و غیره.) استفاده کنید، بنابراین، ممکن است این نکته از یاد برود که برای دسترسی یا تغییر متغیرها، باید از عملیات‌های اتمیک استفاده کرد.

بسته go.uber.org/atomic ایمنی نوع را با پنهان کردن نوع زیرین به این عملیات‌ها اضافه می‌کند. به علاوه، این بسته شامل یک تایپ atomic.Bool نیز می‌شود.

بدخوب
type foo struct {
  running int32  // atomic
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // already running…
     return
  }
  // start the Foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // race!
}
type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // already running…
     return
  }
  // start the Foo
}

func (f *foo) isRunning() bool {
  return f.running.Load()
}

از متغیرهای سراسری تغییرپذیر (Mutable Globals) خودداری کنید

از تزریق وابستگی (Dependency Injection) بجای تغییر متغیرهای سراسری استفاده کنید. این مورد روی اشاره‌گرهای تابع (function pointers) و همچنین برای انواع مقادیر دیگر نیز اعمال می‌شود.

بدخوب
// sign.go

var _timeNow = time.Now

func sign(msg string) string {
  now := _timeNow()
  return signWithTime(msg, now)
}
// sign.go

type signer struct {
  now func() time.Time
}

func newSigner() *signer {
  return &signer{
    now: time.Now,
  }
}

func (s *signer) Sign(msg string) string {
  now := s.now()
  return signWithTime(msg, now)
}
// sign_test.go

func TestSign(t *testing.T) {
  oldTimeNow := _timeNow
  _timeNow = func() time.Time {
    return someFixedTime
  }
  defer func() { _timeNow = oldTimeNow }()

  assert.Equal(t, want, sign(give))
}
// sign_test.go

func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }

  assert.Equal(t, want, s.Sign(give))
}

از جاسازی نوع ها (Embedding Types) در ساختارهای عمومی خودداری کنید

نوع‌های جاسازی شده (embedded types) جزئیات اطلاعات پیاده‌سازی را فاش می‌کنند، توسعه تایپ را دشوارتر می‌کنند و از وضوح مستندات می‌کاهند.

فرض کنید شما انواع مختلفی از لیست‌ها را با استفاده از یک AbstractList مشترک پیاده‌سازی کرده‌اید. از تعبیه کردن (embedding) AbstractList در پیاده‌سازی‌های خاص لیست‌های خود پرهیز کنید. به جای آن، تنها متدهایی را به صورت دستی برای لیست خاص خود ایجاد کنید که به AbstractList ارجاع می‌دهند.

type AbstractList struct {}

// Add یک موجودیت را به لیست اضافه می کند.
func (l *AbstractList) Add(e Entity) {
  // ...
}

// Remove یک موجودیت را از لیست حذف می کند.
func (l *AbstractList) Remove(e Entity) {
  // ...
}
بدخوب
// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
  *AbstractList
}
// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
  list *AbstractList
}

// Add یک موجودیت را به لیست اضافه می کند.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove یک موجودیت را از لیست حذف می کند.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

زبان Go امکان type embedding را به عنوان یک توافق بین ارث‌بری و ترکیب فراهم می‌کند. نوع بیرونی (outer type) نسخه‌های ضمنی از متدهای نوع تعبیه‌شده را به طور ضمنی به ارث می‌برد و این متدها به طور پیش‌فرض به متد مشابه در نمونه تعبیه‌شده ارجاع داده می‌شوند.

همچنین، ساختار (struct) فیلدی با همان نام نوع تعبیه شده را دریافت می کند. بنابراین، اگر نوع تعبیه‌شده عمومی باشد، فیلد عمومی است. برای حفظ توانایی کار کردن کد‌های قدیمی با نسخه‌های جدید، هر نسخه بعدی از نوع خارجی باید نوع تعبیه شده را حفظ کند.

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

حتی اگر یک رابط (interface) AbstractList سازگار را جایگزین ساختار (struct) کنید، به توسعه‌دهنده امکان بیشتری برای تغییر در آینده ارائه می‌دهد، اما همچنان جزئیات استفاده از یک پیاده‌سازی انتزاعی برای concrete لیست‌ها را فاش می‌کند.

بدخوب
// AbstractList یک پیاده سازی کلی از لیست های موجودیت های مختلف است.
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}

// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
  AbstractList
}
// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
  list AbstractList
}

// Add یک موجودیت را به لیست اضافه می کند.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove یک موجودیت را از لیست حذف می کند.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

استفاده از ساختارهای تعبیه شده (embedded struct) یا رابط‌های تعبیه شده (embedded interface)، تکامل و توسعه Typeها را محدود می کند.

  • افزودن متدها به یک رابط تعبیه‌شده تغییرات مخربی ایجاد می‌کند.
  • حذف متدها از یک ساختار تعبیه‌شده نیز تغییر مخربی محسوب می‌شود.
  • حذف نوع تعبیه‌شده همچنین به عنوان یک تغییر مخرب در نظر گرفته می‌شود.
  • حتی جایگزین کردن نوع تعبیه‌شده با یک نوع جایگزین که همان رابط مشابه را پیاده‌سازی می‌کند، تغییر مخربی به حساب می‌آید.

اگرچه نوشتن این متدها (متدهای تعبیه‌شده) کمی زمان‌بر است، اما تلاش اضافی که برای این کار صرف می‌شود، باعث می‌شود جزئیات پیاده‌سازی متدها پنهان شوند. همچنین، این کار فرصت‌های بیشتری برای تغییر در آینده فراهم می‌کند و همچنین این کار به بهبود پایداری و قابلیت تغییر بیشتر کد کمک می‌کند و به از بین بردن انحرافات و پیچیدگی‌های غیر ضروری در مستندات کمک می‌کند.

از استفاده از نام های داخلی (Buit-In) خودداری کنید

مشخصات زبان Go چندین شناسه داخلی و شناسه‌های از پیش اعلام شده را مشخص می کند که نباید در پروژه های Go استفاده شوند.

بسته به زمینه (context)، استفاده مجدد از این شناسه‌ها به عنوان نام، شناسه اصلی را در محدوده فعلی (یا هر محدوده تودرتو) پنهان می‌کند یا کد را مبهم می‌‌کند. در بهترین حالت، کامپایلر یک خطا ایجاد می‌کند؛ در بدترین حالت، چنین کدی ممکن است خطاهایی را ایجاد کند که بازیابی آن‌ها دشوار است.

بدخوب
var error string
// استفاده از نام `error` به عنوان یک متغیر یا شناسه، باعث ایجاد سایه روی نام داخلی `error` می‌شود.

// یا

func handleErrorMessage(error string) {
 // استفاده از نام `error` به عنوان یک متغیر یا شناسه، باعث ایجاد سایه روی نام داخلی `error` می‌شود.
}
var errorMessage string
// در اینجا `error` به عنوان یک متغیر یا شناسه داخلی در نظر گرفته می‌شود

// or

func handleErrorMessage(msg string) {
    // در اینجا `error` به عنوان یک متغیر یا شناسه داخلی در نظر گرفته می‌شود
}
type Foo struct {
// اگرچه این فیلدها از لحاظ فنی سایه‌زنی (shadowing) را ایجاد نمی‌کنند، اما جستجوی رشته‌های `error` یا `string` در این موارد اکنون مبهم است.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` و `f.error` از نظر بصری مشابه هم هستند.
    return f.error
}

func (f Foo) String() string {
    // `string` و `f.string` از نظر بصری مشابه هم هستند.
    return f.string
}
type Foo struct {
    // `error` و `string` اکنون واضح هستند.
    err error
    str string
}

func (f Foo) Error() error {
    return f.err
}

func (f Foo) String() string {
    return f.str
}

توجه داشته باشید که کامپایلر هنگام استفاده از شناسه‌های پیش‌تعریف شده خطا ایجاد نمی کند، اما ابزارهایی مانند go vet به درستی به مشکلات ضمنی در این موارد و موارد دیگر اشاره می کنند.

از تابع ()init استفاده نکنید

در صورت امکان، از ()init پرهیز کنید. وقتی لازم به استفاده از تابع ()init هستید، کد باید تلاش کند:

  1. بدون توجه به محیط برنامه یا نحوه فراخوانی، کاملاً قطعی عمل کند.
  2. سعی کنید از ایجاد وابستگی به ترتیب اجرا یا اثرات جانبی مربوط به توابع ()init دیگر خودداری کنید.هرچند ترتیب اجرای توابع ()init به خوبی شناخته شده است، اما کد ممکن است تغییر کند و این وابستگی‌ها می‌توانند منجر به ناپایداری و خطاهای پنهان شوند.
  3. از دسترسی یا دستکاری وضعیت سراسری یا محیطی مانند اطلاعات ماشین، متغیرهای محیطی، دایرکتوری کاری، آرگومان‌ها/ورودی‌های برنامه و غیره پرهیز کند.
  4. از عملیات ورود/خروج (I/O) مانند عملیات فایل‌سیستم، شبکه و تماس‌های سیستمی پرهیز کند.

کدی که نمی‌تواند این موارد را انجام دهد، احتمالاً بهتر است به عنوان یک تابع کمکی برای فراخوانی در ()main (یا در جای دیگر در چرخه عمر برنامه) قرار گیرد یا به عنوان بخشی از خود ()main نوشته شود. به خصوص کتابخانه‌هایی که برای استفاده در برنامه‌های دیگر طراحی شده‌اند، باید مراقبت‌های خاصی را برای تضمین قطعیت کامل داشته باشند و از "جادوی init" پرهیز کنند.

بدخوب
type Foo struct {
    // ...
}

var _defaultFoo Foo

func init() {
    _defaultFoo = Foo{
        // ...
    }
}
var _defaultFoo = Foo{
    // ...
}

// یا برای تست پذیری بهتر:

var _defaultFoo = defaultFoo()

func defaultFoo() Foo {
    return Foo{
        // ...
    }
}
type Config struct {
    // ...
}

var _config Config

func init() {
    // بد: بر اساس دایرکتوری فعلی
    cwd, _ := os.Getwd()

    // بد: I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )

    yaml.Unmarshal(raw, &_config)
}
type Config struct {
    // ...
}

func loadConfig() Config {
    cwd, err := os.Getwd()
    // handle err

    raw, err := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // handle err

    var config Config
    yaml.Unmarshal(raw, &config)

    return config
}

با توجه به موارد فوق، شرایطی وجود دارد که ممکن است ()init ارجح یا ضروری باشد، که ممکن است شامل موارد زیر باشد:

  • عبارات پیچیده که نمی‌توانند به عنوان انتساب‌های تکی نمایان شوند. (مثلا اگر یک متغیر را نمی‌توان با یک عبارت ساده از نوع x := value مقداردهی کرد و نیاز به انجام محاسبات پیچیده‌تری دارید، در این صورت ممکن است از ()init استفاده کنید.)
  • قلاب های قابل اتصال، مانند پایگاه داده/sql، رجیستری نوع رمزگذاری و غیره.
  • بهینه‌سازی‌ها برای Google Cloud توابع و سایر اشکال پیش محاسبه قطعی.

خروج فقط در تابع اصلی (Main)

برنامه‌های Go برای خروج فوری از برنامه از os.Exit یا log.Fatal* استفاده می‌کنند. (استفاده از panic به عنوان روشی برای خروج از برنامه مناسب نیست، لطفاً از panic استفاده نکنید.)

تنها در تابع ()main از یکی از os.Exit یا log.Fatal* استفاده کنید. توابع دیگر برای اعلام شکست باید خطاها را به عنوان نتیجه برگردانند.

بدخوب
func main() {
  body := readFile(path)
  fmt.Println(body)
}

func readFile(path string) string {
  f, err := os.Open(path)
  if err != nil {
    log.Fatal(err)
  }

  b, err := io.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  return string(b)
}
func main() {
  body, err := readFile(path)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(body)
}

func readFile(path string) (string, error) {
  f, err := os.Open(path)
  if err != nil {
    return "", err
  }

  b, err := io.ReadAll(f)
  if err != nil {
    return "", err
  }

  return string(b), nil
}

در اصل: برنامه‌هایی که دارای تعدادی تابع هستند که دارای توابع خروج فوری هستند، چندین مسئله را با خود به همراه دارند:

  • جریان کنترل ناپیدا: هر تابعی ممکن است باعث خروج برنامه شود، به همین دلیل تبدیل به یک موضوع سخت برای استدلال در مورد کنترل جریان می‌شود.
  • دشواری در تست: توابعی که برنامه را خارج می‌کنند باعث می‌شوند فرآیند تست متوقف شود. این باعث می‌شود که تست کردن این توابع دشوار شود و منجر به خطر از دست دادن تست‌های دیگری که هنوز توسط go test اجرا نشده‌اند، شود.
  • پاکسازی نادیده گرفته شده: وقتی یک تابع باعث خروج برنامه می‌شود، اجرای توابعی که با استفاده از عبارت‌های defer ثبت شده‌اند را نادیده می‌گیرد. این کار باعث افزایش خطر از دست دادن وظایف پایانی مهم می‌شود.

فقط یکبار از یکی از توابع خروج استفاده کنید (Exit Once)

در صورت امکان، حداکثر یک بار os.Exit یا log.Fatal را در تابع ()main خود فراخوانی کنید. اگر چندین حالت خطا وجود دارد که اجرای برنامه را متوقف می‌کنند، این منطق را در یک تابع مستقل قرار دهید و از آنجا خطاها را برگردانید.

این کار باعث کوتاه شدن تابع ()main شما می‌شود و همه منطق اصلی کسب‌وکار را در یک تابع مستقل قرار می‌دهد که قابلیت تست آن را بهبود می‌دهد.

بدخوب
package main

func main() {
  args := os.Args[1:]
  if len(args) != 1 {
    log.Fatal("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()

  // اگر بعد از این خط log.Fatal را فراخوانی کنیم، f.Close فراخوانی نمی شود.

  b, err := io.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }

  // ...
}
package main

func main() {
  if err := run(); err != nil {
    log.Fatal(err)
  }
}

func run() error {
  args := os.Args[1:]
  if len(args) != 1 {
    return errors.New("missing file")
  }
  name := args[0]

  f, err := os.Open(name)
  if err != nil {
    return err
  }
  defer f.Close()

  b, err := io.ReadAll(f)
  if err != nil {
    return err
  }

  // ...
}

مثال بالا از log.Fatal استفاده می کند، اما این راهنما می‌تواند برای os.Exit یا هر کد کتابخانه ای که os.Exit را فراخوانی می کند نیز اعمال می شود.

func main() {
  if err := run(); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

شما می‌توانید امضای تابع ()run را مطابق با نیازهای خود تغییر دهید. به عنوان مثال، اگر برنامه شما باید با کدهای خروج خاصی برای خطاها خارج شود، ()run می‌تواند به جای یک خطا، کد خروج را برگرداند. همچنین به تست های واحد اجازه می دهد تا مستقیماً این رفتار را تأیید کنند.

func main() {
  os.Exit(run(args))
}

func run() (exitCode int) {
  // ...
}

لطفاً توجه داشته باشید که تابع ()run مورد استفاده در این مثال‌ها استفاده شده است اجباری نیست. نام، امضا و تنظیمات تابع ()run انعطاف پذیر هستند. از جمله موارد دیگر، می توانید:

  • آرگومان‌های خط فرمان تجزیه نشده (unparsed) را می‌پذیرد به عنوان مثال (run(os.Args[1:]))
  • آرگومان‌های خط فرمان را در ()main تجزیه کنید (parse) و آنها را برای اجرا ارسال کنید
  • با استفاده از تعریف یک تایپ خطای سفارشی، کد خروج را به ()main برگردانید
  • منطق کسب و کار را در لایه‌های انتزاعی مختلف (package main)بسته اصلی قرار دهید

با این راهنمود، تنها یک مکان در تابع ()main شما وجود دارد که واقعاً مسئول خروج از پروسه است.

از برچسب های فیلد در ساختارهای مارشال شده (marshaled) استفاده کنید

هر فیلدی که به فرمت‌هایی مانند JSON، YAML یا سایر فرمت‌هایی که از نام‌گذاری بر اساس تگ‌ها پشتیبانی می‌کنند، باید با تگ مربوطه مشخص شود.

بدخوب
type Stock struct {
  Price int
  Name  string
}

bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})
type Stock struct {
  Price int    `json:"price"`
  Name  string `json:"name"`
  // ایمن برای تغییر نام به نماد.
}

bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

گوروتین‌ها سبک هستند، اما رایگان نیستند: حداقل هزینه‌هایی را برای استفاده از حافظه برای پشته آن‌ها و زمان CPU برای زمان‌بندی آن‌ها دارند. این هزینه‌ها در موارد معمولی کمترین تأثیر را دارند، اما زمانی که تعداد زیادی گوروتین بدون مدیریت صحیح ایجاد می‌شوند، می‌توانند به مشکلات عملکردی بزرگی منجر شوند. همچنین، گوروتین‌هایی که مدیریت زمان‌های چرخه حیات مشخصی ندارند، می‌توانند به مشکلات دیگری نیز منجر شوند، مثل جلوگیری از جمع‌آوری زباله‌ها (garbage collected) و نگه‌داشتن منابعی که دیگر استفاده نمی‌شوند.

گوروتین ها را به حال خودشان (بدون نظارت) رها نکنید

گوروتین‌ها سبک هستند، اما رایگان نیستند: حداقل هزینه‌هایی را برای استفاده از حافظه برای پشته آن‌ها و زمان CPU برای زمان‌بندی آن‌ها دارند. این هزینه‌ها در موارد معمولی کمترین تأثیر را دارند، اما زمانی که تعداد زیادی گوروتین بدون مدیریت صحیح ایجاد می‌شوند، می‌توانند به مشکلات عملکردی بزرگی منجر شوند. همچنین، گوروتین‌هایی که بدون مدیریت زمان‌های چرخه حیات مشخصی ایجاد می‌شوند، می‌توانند به مشکلات دیگری نیز منجر شوند، مثل جلوگیری از جمع‌آوری زباله‌ها (garbage collected) و نگه‌داشتن منابعی که دیگر استفاده نمی‌شوند.

بنابراین، از لو رفتن (leak) گوروتین‌ها در کد تولیدی (production code) جلوگیری کنید. برای تست نشتی گوروتین داخل پکیج‌هایی که ممکن است گوروتین ایجاد کنند، از go.uber.org/goleak استفاده کنید.

بطور کلی، هر گوروتین باید:

  • یک زمان پیش‌بینی‌شده برای متوقف شدن داشته باشد؛ یا
  • باید یک راه برای اعلام به گوروتین وجود داشته باشد که باید متوقف شود.

در هر دو مورد، باید یک روش وجود داشته باشد تا کد بلاک شده و منتظر اتمام گوروتین شود.

برای مثال:

بدخوب
go func() {
  for {
    flush()
    time.Sleep(delay)
  }
}()
var (
  stop = make(chan struct{}) // به گوروتین می گوید که متوقف شود
  done = make(chan struct{}) // به ما می گوید که گوروتین خارج شد
)
go func() {
  defer close(done)

  ticker := time.NewTicker(delay)
  defer ticker.Stop()
  for {
    select {
    case <-ticker.C:
      flush()
    case <-stop:
      return
    }
  }
}()

// خارج از محدوده گوروتین(در جایی دیگر)...
close(stop)  // به گوروتین علامت دهید که متوقف شود
<-done       // و صبر کنید تا خارج شود

هیچ راهی برای متوقف کردن این گوروتین وجود ندارد. گوروتین تا زمانی که برنامه خارج شود اجرا می شود.

این گوروتین را می توان با close(stop) متوقف کرد، و می توانیم منتظر خروج آن با done-> باشیم.

منتظر خروج گوروتین ها باشید

با توجه به گوروتین ایجاد شده توسط سیستم، باید راهی برای انتظار خروج گوروتین وجود داشته باشد. دو روش رایج برای انجام این کار وجود دارد:

  • اگر چندین گوروتین دارید که می‌خواهید منتظر آن‌ها بمانید از sync.WaitGroup استفاده کنید.

    var wg sync.WaitGroup
    for i := 0; i < N; i++ {
      wg.Add(1)
      go func() {
        defer wg.Done()
        // ...
      }()
    }
    
    // صبر کنید تا همه چیز تمام شود:
    wg.Wait()
  • اگر تنها یک گوروتین وجود دارد، بهتر است یک کانال {}chan struct دیگر ایجاد کنید که گوروتین آن را پس از انجام کار ببندد. به این ترتیب می‌توانید انتظار برای اتمام گوروتین را داشته باشید. این کار به شما اجازه می‌دهد تا بدون استفاده از sync.WaitGroup منتظر اتمام گوروتین باشید.

    done := make(chan struct{})
    go func() {
      defer close(done)
      // ...
    }()
    
    // برای اینکه منتظر بمانید تا کار گوروتین تمام شود:
    <-done

از گوروتین ها در تابع ()init استفاده نکنید

init() functions should not spawn goroutines. See also Avoid init().

توابع ()init نباید گوروتین‌ها را راه‌اندازی کنند. همچنین دیگر مواردی که استفاده از تابع ()init را توصیه نمی‌کند:

اگر یک بسته (package) نیاز به یک گوروتین پس‌زمینه دارد، باید یک شی ارائه دهد که مسئولیت مدیریت چرخه حیات گوروتین را بر عهده دارد. این شی باید یک متد (مانند Close, Stop, Shutdown و غیره) ارائه دهد که به گوروتین پس‌زمینه اعلام کند که باید متوقف شود و منتظر اتمام آن بماند.

بدخوب
func init() {
  go doWork()
}

func doWork() {
  for {
    // ...
  }
}
type Worker struct{ /* ... */ }

func NewWorker(...) *Worker {
  w := &Worker{
    stop: make(chan struct{}),
    done: make(chan struct{}),
    // ...
  }
  go w.doWork()
}

func (w *Worker) doWork() {
  defer close(w.done)
  for {
    // ...
    case <-w.stop:
      return
  }
}

// خاموش شدن (Shutdown) به workder می گوید که متوقف شود و صبر کند تا کار تمام شود.
func (w *Worker) Shutdown() {
  close(w.stop)
  <-w.done
}

زمانی که کاربر این بسته (package) را (export) می‌کند، یک گوروتین پس‌زمینه بدون شرطی ایجاد می‌شود. کاربر هیچ کنترلی بر روی گوروتین ندارد و هیچ وسیله‌ای برای متوقف کردن آن وجود ندارد.

گوروتین، worker را فقط در صورتی ایجاد می‌کند که کاربر آن را درخواست کند. همچنین امکانی برای shutdownکردن worker فراهم می‌کند تا کاربر بتواند منابع مورد استفاده توسط worker را آزاد کند.

توجه داشته باشید که اگر worker مدیریت چندین گوروتین را انجام می‌دهد، باید از WaitGroups استفاده کنید. برای جزئیات بیشتر به منتظر اتمام گوروتین‌ها باشید

کارایی (Performance)

دستورالعمل‌های مربوط به عملکرد، تنها به مسیر اصلی (hot path) اعمال می‌شوند.

پکیج strconv را به fmt ترجیح دهید

وقتی می‌خواهید primitives را به string تبدیل کنید یا برعکس، بهتر است از بسته strconv استفاده کنید چرا که عملکرد این بسته سریع‌تر از بسته fmt است.

بدخوب
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}
BenchmarkFmtSprint-4    143 ns/op    2 allocs/op
BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

از تبدیل رشته به بایت (string-to-byte) خودداری کنید

به طور مکرر برش‌های بایت (byte slices) را از stringهای ثابت ایجاد نکنید. بجای اینکار، یک تبدیل انجام دهید و نتیجه را ثبت کنید.

BadGood
for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}
BenchmarkBad-4   50000000   22.2 ns/op
BenchmarkGood-4  500000000   3.25 ns/op

ترجیحا ظرفیت کانتینر (container) را مشخص کنید

تا جایی که امکان دارد ظرفیت کانتینر را مشخص کنید تا حافظه از قبل برای کانتینر تخصیص داده شود. این امر تخصیص های بعدی (با کپی و تغییر اندازه ظرف) را هنگام افزودن عناصر به حداقل می رساند.

تعیین حداکثر ظرفیت ممکن Map

در صورت امکان، هنگام مقداردهی اولیه Mapها با ()make اندازه ظرفیت آن را مشخص کنید.

make(map[T1]T2, hint)

مشخص کردن ظرفیت به ()make باعث ایجاد Map در زمان مقداردهی اولیه می‌شود، که در صورت اضافه شدن عناصر به Map، از تخصیص مجدد حافظه برای Map جلوگیری می‌کند.

در واقعیت، تعیین ظرفیت Map با استفاده از تابع ()make نمی‌تواند به صورت دقیق و کامل تعداد دقیق buckets مورد نیاز برای یک hashmap را پیش‌بینی کند. به جای اینکه به صورت کامل پیش‌بینی شده باشد، این تعیین ظرفیت تقریبا buckets مورد نیاز برای hashmap را ارائه می‌دهد. به عبارت دیگر، حتی با تعیین یک ظرفیت خاص، ممکن است در هنگام افزودن عناصر به Map، تخصیص‌ها (allocation) انجام شود.

بدخوب
m := make(map[string]os.FileInfo)

files, _ := os.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
files, _ := os.ReadDir("./files")

m := make(map[string]os.DirEntry, len(files))
for _, f := range files {
    m[f.Name()] = f
}

متغیر m بدون تعیین اندازه ایجاد شده است؛ بنابراین ممکن است در زمان اختصاص (assignment) عناصر به m تخصیص‌های بیشتری ایجاد شود.

متغیر m با یک اشاره به اندازه ایجاد شده است؛ بنابراین ممکن است در زمان اختصاص (assignment) عناصر به m تخصیص‌های کمتری ایجاد شود.

تعیین ظرفیت برش(slice)

در صورت امکان، هنگام مقداردهی اولیه sliceها با استفاده از تابع ()make، مقدار ظرفیت راتعیین کنید، به ویژه هنگام اضافه کردن عناصر.

make([]T, length, capacity)

برخلاف Mapها، ظرفیت برش(Slice) نیازی به مشخص کردن ظرفیت آرایه در زمان ایجاد آن ندارد: به این معنا که عملیات‌های بعدی ()append هیچ تخصیص حافظه‌ای را در پی ندارند (تا زمانی که طول آرایه با ظرفیت مطابقت داشته باشد، پس از آن هر append به منظور نگهداری عناصر اضافی نیاز به تغییر اندازه دارد).

بدخوب
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s
BenchmarkGood-4   100000000    0.21s

استایل (style)

از خطوط بیش از حد طولانی خودداری کنید

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

ما محدودیت طول خط نرم 99 کاراکتر را توصیه می کنیم. نویسندگان باید قبل از رسیدن به این حد، خطوط را wrap کنند، اما این یک محدودیت دقیق نیست. اجازه داده شده است که کد این محدودیت را تجاوز کند.

یکپارچگی را رعایت کنید

برخی از معیارهای ذکر شده در این مقاله، ارزیابی های عینی، بر اساس موقعیت یا سناریو، زمینه (context)، یا قضاوت های ذهنی هستند.

مهمتر از همه اینا، پیوستگی را حفظ کنید.

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

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

هنگام اعمال این استانداردها در یک codebase، توصیه می‌شود که تغییرات در سطح پکیج (یا بزرگتر) اعمال شود، با اجرای این تغییرات در سطح زیربسته (sub-package) می‌تواند نگرانی‌های فوق را با معرفی چندین سبک در یک کد نقض کند.

تعاریف مشابه را گروه بندی کنید

زبان Go از گروه‌بندی اعلان‌های مشابه پشتیبانی می‌کند.

بدخوب
import "a"
import "b"
import (
  "a"
  "b"
)

همین امر در مورد ثابت‌ها، متغیرها و اعلان‌ تایپ‌ها صدق می‌کند:

بدخوب
const a = 1
const b = 2



var a = 1
var b = 2



type Area float64
type Volume float64
const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)

فقط اعلان‌های مرتبط را گروه‌بندی کنید. اعلان‌های غیرمرتبط را گروه‌بندی نکنید.

بدخوب
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
  EnvVar = "MY_ENV"
)
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

const EnvVar = "MY_ENV"

هیچ محدودیتی برای استفاده از گروه‌ها وجود ندارد، به عنوان مثال: می‌توانید از آنها در داخل توابع استفاده کنید:

بدخوب
func f() string {
  red := color.New(0xff0000)
  green := color.New(0x00ff00)
  blue := color.New(0x0000ff)

  // ...
}
func f() string {
  var (
    red   = color.New(0xff0000)
    green = color.New(0x00ff00)
    blue  = color.New(0x0000ff)
  )

  // ...
}

استثنا: اعلان‌های متغیر (مخصوصاً آنهایی که درون توابع هستند) در صورت مجاورت با متغیرهای دیگر باید با هم گروه‌بندی شوند. این کار را برای متغیرهای اعلام شده با هم انجام دهید، حتی اگر نامرتبط باشند.

بدخوب
func (c *client) request() {
  caller := c.name
  format := "json"
  timeout := 5*time.Second
  var err error

  // ...
}
func (c *client) request() {
  var (
    caller  = c.name
    format  = "json"
    timeout = 5*time.Second
    err error
  )

  // ...
}

مرتب سازی گروهی واردات (imports)

واردات باید به دو دسته تقسیم شود:

  • کتابخانه استاندارد
  • سایر کتابخانه ها این گروه بندی است که توسط goimports به طور پیش فرض اعمال می شود.
بدخوب
import (
  "fmt"
  "os"
  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)
import (
  "fmt"
  "os"

  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)

نام گذاری بسته ها (Package Names)

هنگام نامگذاری بسته‌ها(packages)، نامی را انتخاب کنید که:

  • تمام حروف کوچک. بدون حروف بزرگ یا زیرخط.
  • در بیشتر موارد هنگام استفاده از واردات نامگذاری شده، تغییر نام مورد نیاز نیست.
  • کوتاه و مختصر باشد. به یاد داشته باشید که نام را در هر کجا که استفاده می شود کاملاً مشخص کنید. نیازی به جمع نیست. به عنوان مثال net/url، نه net/urls.
  • از "common"، "util"، "shared" یا "lib" استفاده نکنید. اینها اسامی بد و بی معنی هستند.

همچنین راهنمای نام‌گذاری بسته(Package Names) و راهنمای استایل بسته(package) Go را ببینید.

نام گذاری توابع (Function Names)

ما از روش رایج جامعه Go با استفاده از MixedCaps برای نام‌گذاری توابع پیروی می‌کنیم. یک استثناء برای توابع تست وجود دارد که ممکن است شامل زیرخط (_) به منظور گروه‌بندی موارد تست مرتبط باشد، به عنوان مثال، TestMyFunction_WhatIsBeingTested.

نام مستعار واردات (Import)

در صورتی که نام پکیج با آخرین بخش مسیر (import path) مطابقت نداشته باشد، باید از نام‌گذاری مخفف (aliasing) برای import استفاده شود.

import (
  "net/http"

  client "example.com/client-go"
  trace "example.com/trace/v2"
)

در سایر موارد، باید از نام‌گذاری مخفف (aliasing) در import خودداری شود مگر اینکه تداخل مستقیمی بین imports وجود داشته باشد.

بدخوب
import (
  "fmt"
  "os"


  nettrace "golang.net/x/trace"
)
import (
  "fmt"
  "os"
  "runtime/trace"

  nettrace "golang.net/x/trace"
)

گروه بندی و مرتب سازی توابع

  • توابع باید بر اساس تقریبی فراخوانیشان مرتب شوند(منظور این است که توابعی که بیشترین احتمال برای فراخوانی آنها وجود دارد در ابتدا آورده می‌شوند و توابعی که کمترین احتمال فراخوانی رو دارند در انتها قرار می‌گیرند).
  • توابع موجود در یک فایل باید بر اساس گیرنده (receiver) گروه بندی شوند(منظور این است که توابعی که بر روی یک نوع خاص عمل می‌کنند، در یک قسمت مشخص از کد قرار می‌گیرند. این کار به ترتیب و منظم شدن کدها کمک می‌کند و به توسعه دهندگان کمک می‌کند تا توابع مرتبط با هم را به راحتی پیدا کنند).

بنابراین، توابع صادرشده (exported) باید بعد از تعریف‌های struct, const, var در ابتدای فایل ظاهر شوند.

توابعی که با ()newXYZ/()NewXYZ شروع می‌شوند، ممکن است بعد از تعریف نوع (type) قبل از باقی متدهای دریافت‌کننده (receiver) ظاهر شوند.

از آنجایی که توابع توسط گیرنده (receiver) گروه‌بندی می‌شوند، توابع utility باید در انتهای فایل ظاهر شوند.

بدخوب
func (s *something) Cost() {
  return calcCost(s.weights)
}

type something struct{ ... }

func calcCost(n []int) int {...}

func (s *something) Stop() {...}

func newSomething() *something {
    return &something{}
}
type something struct{ ... }

func newSomething() *something {
    return &something{}
}

func (s *something) Cost() {
  return calcCost(s.weights)
}

func (s *something) Stop() {...}

func calcCost(n []int) int {...}

تورفتگی (Nesting) را کاهش دهید

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

بدخوب
for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}
for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

اجتناب از Elseهای غیر ضروری

اگر یک متغیر در هر دو شاخه (شرط true و شرط false) یک دستور if تنظیم می‌شود، می‌توانید از یک if تنها استفاده کنید.

بدخوب
var a int
if b {
  a = 100
} else {
  a = 10
}
a := 10
if b {
  a = 100
}

تعاریف متغیرهای سطح بالا

در ابتدای کد، از واژه کلیدی معمول var استفاده کنید. در صورتی که نوع متغیر مطابق نوع عبارت مقداردهی باشد، نیازی به مشخص کردن نوع نیست.

بدخوب
var _s string = F()

func F() string { return "A" }
var _s = F()
// از آنجایی که F قبلاً بیان می کند که یک رشته را برمی گرداند، نیازی به تعیین مجدد نوع آن نداریم.

func F() string { return "A" }

اگر نوع عبارت دقیقاً با نوع مورد نیاز مطابقت ندارد، نوع آن را مشخص کنید.

type myError struct{}

func (myError) Error() string { return "error" }

func F() myError { return myError{} }

var _e error = F()
// F یک شی از نوع myError را برمی گرداند اما ما خطا می خواهیم.

از پیشوند "_" برای متغیرهای خصوصی (Unexported) استفاده کنید

به منظور افزایش دقت و وضوح، متغیرها(vars) و ثابت‌هایی(consts) که عموماً در سطح بالای کد (یعنی در دسترسی پکیج) قرار می‌گیرند، با استفاده از نشانه "_" (زیرخط) قبل از نام آن‌ها ترکیب شوند. این کار باعث می‌شود که هنگام استفاده از آن‌ها در دیگر بخش‌های کد، به وضوح متوجه شود که این متغیرها و ثابت‌ها به عنوان نمادهای سراسری (global) در نظر گرفته شوند.

دلیل: متغیرها و ثابت‌های سطح بالا در محدوده‌ی پکیج قرار دارند و تا حدی کلی هستند. استفاده از نام‌های عمومی ممکن است باعث اشتباه در استفاده از مقادیر اشتباه در فایل‌های دیگر شود.

بدخوب
// foo.go

const (
  defaultPort = 8080
  defaultUser = "user"
)

// bar.go

func Bar() {
  defaultPort := 9090
  ...
  fmt.Println("Default port", defaultPort)

  // اگر خط اول Bar() حذف شود، خطای کامپایل نخواهیم دید.
}
// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

استثنا: در مواردی که مقادیر خطا (error) به صورت unexported باشند، می‌توانید از پیشوند err بدون خط زیر (underscore) استفاده کنید. به منظور اطلاعات بیشتر در مورد نام‌گذاری خطا، به نام‌گذاری خطا مراجعه کنید.

جاسازی (Embedding) در ساختارها

اگر نوع‌های تو در تو (embedded types) در یک struct وجود دارند، آنها باید در بالای لیست فیلدهای struct قرار گیرند، و باید یک خط خالی بین فیلدهای تو در تو و فیلدهای معمولی وجود داشته باشد.

بدخوب
type Client struct {
  version int
  http.Client
}
type Client struct {
  http.Client

  version int
}

درج نوع‌های دیگر در یک ساختار (embedding) باید به صورتی باشد که به ویژگی‌ها یا قابلیت‌ها به یک شکل منطقی و معنادار افزوده یا تقویت کند. این کار باید بدون تأثیر منفی قابل مشاهده برای کاربران انجام شود (برای اطلاعات بیشتر، "همچنین: از جاسازی نوع‌ها (Embedding Types) در ساختارهای عمومی خودداری کنید" را ببینید).

استثناء: حتی در نوع‌های (unexported) هم، Mutexها (قفل‌های همزمانی) نباید به صورت تعبیه شده درج شوند. همچنین می‌توانید به مقدار صفر (zero-value) Mutexها معتبر هستند مراجعه کنید.

تعبیه (Embedding) نباید:

  • صرفا به منظور زیبایی یا افزایش راحتی باشد.
  • ساختن یا استفاده از نوعهای خارجی را پیچیده‌تر کند.
  • باعث تغییر در مقدار-صفر (zero value) نوع خارجی شود. . اگر نوع خارجی، مقدار صفر مفیدی دارد، پس از تعبیه نوع داخلی، همچنان باید مقدار صفر مفید داشته باشد.
  • توابع یا فیلدهای غیرمرتبط از نوع خارجی را به عنوان نتیجه تعبیه نمایش دهد.
  • نوع‌های (unexported) را نمایش دهد.
  • اثرات کپی (copy) انواع خارجی را تغییر دهد.
  • API یا معناشناسی انواع خارجی را تغییر دهد.
  • یک نمایش غیرمعمول از نوع داخلی را ارائه دهد.
  • جزئیات پیاده‌سازی نوع خارجی را نشان دهد.
  • به کاربران اجازه مشاهده یا کنترل اطلاعات داخلی نوع را بدهد.
  • با تغییر رفتار کلی عملکردهای داخلی موقعیت‌های غیرمنتظره ای را برای کاربران به ارمغان بیاورد.

بطور کلی، تعبیه (Embedding) باید با آگاهی و هدف انجام شود. یک آزمون ساده برای این کار این است: "آیا تمام این متدها/فیلدها باید به صورت مستقیم به نوع خارجی اضافه شوند؟" اگر پاسخ "بله" باشد، معقول است که تعبیه انجام شود؛ اگر پاسخ "بخشی از آنها" یا "خیر" باشد، بهتر است از یک فیلد به جای تعبیه استفاده کنید.

بدخوب
type A struct {
// بد: حالا دستورهای A.Lock() و A.Unlock() در دسترس هستند،
// اما فایده‌ای ندارند و به کاربران اجازه می‌دهند که
// جزئیات داخلی A را کنترل کنند.
    sync.Mutex
}
type countingWriteCloser struct {
// خوب: تابع Write() در این لایه بیرونی برای
// یک هدف خاص فراهم شده است و کار را به
// تابع Write() نوع داخلی انتقال می‌دهد.
    io.WriteCloser

    count int
}

func (w *countingWriteCloser) Write(bs []byte) (int, error) {
    w.count += len(bs)
    return w.WriteCloser.Write(bs)
}
type Book struct {
    // بد: اشاره‌گر سودمندی مقدار-صفر را تغییر می‌دهد
    io.ReadWriter

    // other fields
}

// later

var b Book
b.Read(...)  // panic: nil pointer
b.String()   // panic: nil pointer
b.Write(...) // panic: nil pointer
type Book struct {
    // خوب: دارای مقدار-صفر مفید است
    bytes.Buffer

    // other fields
}

// later

var b Book
b.Read(...)  // ok
b.String()   // ok
b.Write(...) // ok
type Client struct {
    sync.Mutex
    sync.WaitGroup
    bytes.Buffer
    url.URL
}
type Client struct {
    mtx sync.Mutex
    wg  sync.WaitGroup
    buf bytes.Buffer
    url url.URL
}

تعاریف متغیرهای محلی

اگر یک متغیر به صراحت به یک مقدار تنظیم می‌شود، باید از اعلان‌های کوتاه متغیر (:=) استفاده شود.

بدخوب
var s = "foo"
s := "foo"

با این حال، مواردی وجود دارد که در آن مقدار پیش‌فرض وقتی که از واژه کلیدی var استفاده می‌شود، واضح‌تر است. برای مثال، در اعلان برش‌های خالی (Empty Slices).

بدخوب
func f(list []int) {
  filtered := []int{}
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}
func f(list []int) {
  var filtered []int
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

خود nil یک برش slice معتبر است

خود nil به عنوان یک برش با طول صفر (length 0) معتبر شناخته می‌شود. این بدان معناست که:

  • شما نباید به صورت صریح یک برش با طول صفر را برگردانید. به جای آن باید nil را برگردانید.

    بدخوب
    if x == "" {
      return []int{}
    }
    if x == "" {
      return nil
    }
  • برای بررسی اینکه آیا یک برش (slice) خالی است یا نه، همیشه از عبارت len(s) == 0 استفاده کنید.نباید برای بررسی خالی بودن از nil استفاده کنید.

    بدخوب
    func isEmpty(s []string) bool {
      return s == nil
    }
    func isEmpty(s []string) bool {
      return len(s) == 0
    }
  • مقدار صفر (یک برش که با var اعلان شده است) بدون نیاز به استفاده از تابع make()، بلافاصله قابل استفاده است.

    بدخوب
    nums := []int{}
    // or, nums := make([]int)
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }
    var nums []int
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }

به خاطر داشته باشید که در حالی که یک برش nil معتبر است، اما با یک برش تخصیص داده شده با طول صفر معادل نیست - یکی از آن‌ها nil است و دیگری نیست - و در شرایط مختلف (مانند فرآیند سریال‌سازی) ممکن است به صورت متفاوتی مدیریت شوند.

کاهش دامنه (scope) متغیرها

در صورت امکان، سعی کنید دامنه متغیرها را محدود کنید. مگر اینکه با قانون تورفتگی (Nesting) را کاهش دهید در تضاد باشد.

بدخوب
err := os.WriteFile(name, data, 0644)
if err != nil {
 return err
}
if err := os.WriteFile(name, data, 0644); err != nil {
 return err
}

اگر نتیجه یک تابع را بیرون از شرط if نیاز دارید، در اینصورت نباید سعی کنید دامنه متغیر را کاهش دهید.

بدخوب
if data, err := os.ReadFile(name); err == nil {
  err = cfg.Decode(data)
  if err != nil {
    return err
  }

  fmt.Println(cfg)
  return nil
} else {
  return err
}
data, err := os.ReadFile(name)
if err != nil {
   return err
}

if err := cfg.Decode(data); err != nil {
  return err
}

fmt.Println(cfg)
return nil

از پارامترهای بی نام (Naked Parameters) خودداری کنید

پارامترهای بی‌نام در فراخوانی توابع می‌توانند خوانایی را کاهش دهند. در صورتی که معنای پارامترها واضح نباشد، نام‌های پارامترها را با کامنت‌ استایل C (/* ... */) اضافه کنید.

بدخوب
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true, true)
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true /* isLocal */, true /* done */)

بهتر است پارامترهای bool بدون نوع خاص را با نوع‌های سفارشی جایگزین کنید تا کد خواناتر و ایمن‌تری داشته باشید. این امکان را به شما می‌دهد تا در آینده بیش از دو وضعیت (true/false) برای این پارامتر داشته باشید.

type Region int

const (
  UnknownRegion Region = iota
  Local
)

type Status int

const (
  StatusReady Status = iota + 1
  StatusDone
  // شاید در آینده StatusInProgress داشته باشیم.
)

func printInfo(name string, region Region, status Status)

استفاده از Raw String Literals برای جلوگیری از Escape شدن کاراکترها

زبان Go از رشته‌های متنی خام (raw string literals) پشتیبانی می‌کند. این نوع رشته‌ها می‌توانند از چندین خط تشکیل شده و شامل نقل قولها باشند. برای افزایش خوانایی کد و جلوگیری از استفاده از رشته‌های دست‌ساز با ویژگی‌های خاص، از رشته‌های متنی خام استفاده کنید. این نوع رشته‌ها خوانایی کد را خیلی بالا میبرند.

بدخوب
wantError := "unknown name:\"test\""
wantError := `unknown error:"test"`

مقداردهی اولیه ساختارها (structs)

استفاده از نام فیلدها برای مقداردهی اولیه ساختارها

تقریباً همیشه باید نام فیلدها را هنگام مقداردهی اولیه ساختارها (structs) مشخص کنید. این توصیه اکنون توسط ابزار go vet اجباری شده است.

بدخوب
k := User{"John", "Doe", true}
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

استثنا: زمانی که تعداد فیلد‌ها سه یا کمتر باشد می‌توانید نام‌ فیلد‌ها را در جداول تست حذف کنید.

tests := []struct{
  op Operation
  want string
}{
  {Add, "add"},
  {Subtract, "subtract"},
}

حذف فیلدهای مقدارصفر (zero value) در ساختارها

در هنگام مقداردهی اولیه به ساختارها (structs) با استفاده از نام‌ فیلدها، فیلدهایی که مقدار صفر (zero value) دارند را حذف کنید مگر اینکه به دلایل معناداری نیاز به آنها داشته باشید. در غیر این صورت، به Go اجازه دهید این فیلدها را به طور خودکار به مقادیر صفر تنظیم کند.

بدخوب
user := User{
  FirstName: "John",
  LastName: "Doe",
  MiddleName: "",
  Admin: false,
}
user := User{
  FirstName: "John",
  LastName: "Doe",
}

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

در صورتی که نام فیلدها مفهومی داشته باشند، مقادیر صفر (zero values) را نیز در نظر بگیرید. به عنوان مثال، در جداول تست (Table-driven tests)، استفاده از نام فیلدها حتی زمانی که این مقادیر صفری (zero values) هستند می‌تواند مفید باشد.

tests := []struct{
  give string
  want int
}{
  {give: "0", want: 0},
  // ...
}

استفاده از var برای ساختارهای مقدارصفر (zero value)

زمانی که تمامی فیلدهای یک ساختار (struct) در یک اعلان حذف شدند، از شکل var برای اعلان ساختار استفاده کنید.

بدخوب
user := User{}
var user User

این کار باعث تفکیک ساختارهای با مقدار صفر از ساختارهای دارای فیلدهای غیر صفر می‌شود، مشابه تفکیکی که برای مقداردهی اولیه Mapها ایجاد شده است، و با روش ترجیحی ما برای اعلان برش‌های خالی هماهنگ می‌شود.

مقداردهی اولیه ساختارهای رفرنس دار

از &T{} به جای new(T) هنگام مقداردهی اولیه ساختار (struct references) استفاده کنید تا با مقداردهی اولیه ساختار مطابقت داشته باشد.

بدخوب
sval := T{Name: "foo"}

// ناسازگار
sptr := new(T)
sptr.Name = "bar"
sval := T{Name: "foo"}

sptr := &T{Name: "bar"}

مقداردهی اولیه Mapها

برای ایجاد نقشه‌های خالی و نقشه‌هایی که به صورت برنامه‌نویسی پر می‌شوند، استفاده از تابع (..)make توصیه می‌شود. این اقدام نه تنها مقداردهی نقشه را از اعلان آن به صورت بصری متمایز می‌کند، بلکه اگر در آینده اندازه (size hints) در دسترس قرار بگیرد، امکان اضافه کردن آنها را آسان می‌سازد.

بدخوب
var (
  // m1 برای خواندن و نوشتن امن است.
  // m2 در نوشتن panic خواهد کرد.
  m1 = map[T1]T2{}
  m2 map[T1]T2
)
var (
  // m1 برای خواندن و نوشتن امن است.
  // m2 در نوشتن panic خواهد کرد.
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)

اعلان و مقداردهی اولیه از نظر بصری مشابه هستند.

اعلان و مقداردهی اولیه از نظر بصری متمایز هستند

در صورت امکان، هنگام مقداردهی اولیه نقشه ها با make() اندازه ظرفیت ارائه دهید. برای اطلاعات بیشتر به تعیین حداکثر ظرفیت ممکن Map مراجعه کنید.

از سوی دیگر، اگر نقشه مجموعه‌ی ثابتی از عناصر را نگه می‌دارد، از نقشه‌های لیترال (map literals) برای مقداردهی اولیه استفاده کنید.

بدخوب
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

قاعده اساسی در استفاده از نقشه‌ها به شکل زیر است: اگر قرار باشد مجموعه‌ی ثابتی از عناصر را در زمان مقداردهی اولیه اضافه کنید، از نقشه‌های لیترال (map literals) استفاده کنید. در غیر اینصورت، از تابع make() استفاده کنید (و در صورت امکان مقدار ظرفیت را مشخص کنید).

قالب بندی رشته ها (strings) خارج از تابع Printf

اگر شما رشته‌های قالب‌بندی (format strings) برای توابع استایل‌دهی، مانند Printf را خارج از رشته معمولی اعلان می‌کنید، آنها را به عنوان مقادیر const ایجاد کنید.

این کمک می‌کند تا go vet تجزیه و تحلیل استاتیک رشته قالب‌بندی را انجام دهد.

بدخوب
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)

نام گذاری توابع به سبک Printf

وقتی یک تابع با استایل Printf اعلان می‌کنید، مطمئن شوید که go vet قادر به شناسایی آن و بررسی رشته قالب‌بندی است.

این بدان معناست که در صورت امکان باید از نام‌های پیش‌تعریف شده برای توابع به سبک Printf استفاده کنید. go vet به طور پیش‌فرض این‌ها را بررسی می‌کند. برای اطلاعات بیشتر، به خانواده Printf مراجعه کنید.

اگر نمی توانید از یک نام از پیش تعریف شده استفاده کنید، نامی را که انتخاب می کنید با f خاتمه دهید: مثلاً Wrapf، بجای Wrap. می‌توان از go vet بخواهیم نام‌های خاص به سبک Printf را بررسی کند، اما باید با f خاتمه یابد.

go vet -printfuncs=wrapf,statusf

همچنین، می‌توانید به مقاله بررسی خانواده Printf توسط go vet مراجعه کنید.

الگوها

جداول تست (Table-driven tests)

استفاده از الگوی تست‌های جدولی با subtests می‌تواند یک الگوی مفید برای نوشتن تست‌ها باشد تا از تکرار کد در زمانی که منطق آزمون اصلی تکرار می‌شود جلوگیری شود.

اگر یک سیستم تحت آزمون تست نیاز به آزمایش در برابر شرایط چندگانه دارد که در آن بخش‌های خاصی از ورودی‌ها و خروجی‌ها تغییر می‌کنند، بهترین روش استفاده از تست‌های جدولی است. این روش کد را کمتر تکراری می‌کند و خوانایی آن را بهبود می‌بخشد.

بدخوب
// func TestSplitHostPort(t *testing.T)

host, port, err := net.SplitHostPort("192.0.2.0:8000")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("192.0.2.0:http")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "http", port)

host, port, err = net.SplitHostPort(":8000")
require.NoError(t, err)
assert.Equal(t, "", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("1:8")
require.NoError(t, err)
assert.Equal(t, "1", host)
assert.Equal(t, "8", port)
// func TestSplitHostPort(t *testing.T)

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  {
    give:     "192.0.2.0:8000",
    wantHost: "192.0.2.0",
    wantPort: "8000",
  },
  {
    give:     "192.0.2.0:http",
    wantHost: "192.0.2.0",
    wantPort: "http",
  },
  {
    give:     ":8000",
    wantHost: "",
    wantPort: "8000",
  },
  {
    give:     "1:8",
    wantHost: "1",
    wantPort: "8",
  },
}

for _, tt := range tests {
  t.Run(tt.give, func(t *testing.T) {
    host, port, err := net.SplitHostPort(tt.give)
    require.NoError(t, err)
    assert.Equal(t, tt.wantHost, host)
    assert.Equal(t, tt.wantPort, port)
  })
}

استفاده از جداول تست (Test tables) کمک می‌کند تا پیام‌های خطا دارای زمینه (context) بیشتری باشند، منطق تکراری را کاهش دهد و امکان افزودن تست‌های جدید را فراهم کند.

ما از این قرارداد پیروی می‌کنیم که برشی از ساختارها (slice of struct) به عنوان تست‌ tests مدنظر است و هر مورد آزمون tt نامیده می‌شود. علاوه بر این، ما توصیه می‌کنیم تا مقادیر ورودی و خروجی برای هر مورد تست را با پیشوندهای give و want به صورت صریح مشخص کنید.

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  // ...
}

for _, tt := range tests {
  // ...
}

جلوگیری از پیچیدگی‌های غیرضروری در تست‌های جدولی

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

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

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

بعضی از اهدافی که باید به آنها دست یابیم عبارتند از:

  • تمرکز بر روی بخش‌های خاص و محدودی از عملکرد
  • کاهش "عمق تست" و اجتناب از ادعاهای شرطی (راهنمایی زیر را ببینید)
  • اطمینان از استفاده از همه‌ی فیلدهای جدول در تمام تست‌ها
  • اطمینان از اجرای منطق تست برای تمام موارد جدول

در این متن، "عمق تست" به معنای "تعداد ادعاهای متوالی در یک تست داده شده است که نیاز به اثبات ادعاهای قبلی دارند" (مشابه به پیچیدگی سیکلوماتیک) است. داشتن "تست‌های کم‌عمق" به این معناست که تعداد ارتباطات بین ادعاها کمتر است و به‌طور مهمتر، این ادعاها به طور پیش‌فرض کمتر از ادعاهای شرطی هستند.

به طور مشخص، تست‌های جدول اگر از مسیرهای انشعاب چندگانه (مانند shouldError، expectCall و غیره) استفاده کنند، از بسیاری از دستورات if برای انتظارات ساختگی خاص (مانند shouldCallFoo) استفاده کنند یا توابعی را در داخل جدول قرار دهند (مثلاً setupMocks func (* FooMock)) می‌توانند گیج‌کننده باشند و درک آن دشوار شود.

با این حال، هنگام آزمایش رفتاری که فقط بر اساس ورودی تغییر یافته تغییر می‌کند، موارد مشابه را در یک آزمون جدول با هم گروه‌بندی می‌‌کنیم تا نحوه تغییر رفتار در همه ورودی‌ها را بهتر نشان دهیم، تا اینکه واحدهای قابل مقایسه را به آزمون‌های جداگانه تقسیم کنیم و انجام آنها را سخت‌تر کنیم.

اگر بدنه تست کوتاه و ساده باشد، می‌توانید برای موارد موفقیت و شکست، یک مسیر اجرایی (شاخه) واحد را در نظر بگیرید که از طریق یک فیلد در جدول تست، مثلاً shouldErr برای تعیین انتظارات خطا، انتخاب شود.

بدخوب
func TestComplicatedTable(t *testing.T) {
  tests := []struct {
    give          string
    want          string
    wantErr       error
    shouldCallX   bool
    shouldCallY   bool
    giveXResponse string
    giveXErr      error
    giveYResponse string
    giveYErr      error
  }{
    // ...
  }

  for _, tt := range tests {
    t.Run(tt.give, func(t *testing.T) {
      // setup mocks
      ctrl := gomock.NewController(t)
      xMock := xmock.NewMockX(ctrl)
      if tt.shouldCallX {
        xMock.EXPECT().Call().Return(
          tt.giveXResponse, tt.giveXErr,
        )
      }
      yMock := ymock.NewMockY(ctrl)
      if tt.shouldCallY {
        yMock.EXPECT().Call().Return(
          tt.giveYResponse, tt.giveYErr,
        )
      }

      got, err := DoComplexThing(tt.give, xMock, yMock)

      // verify results
      if tt.wantErr != nil {
        require.EqualError(t, err, tt.wantErr)
        return
      }
      require.NoError(t, err)
      assert.Equal(t, want, got)
    })
  }
}
func TestShouldCallX(t *testing.T) {
  // setup mocks
  ctrl := gomock.NewController(t)
  xMock := xmock.NewMockX(ctrl)
  xMock.EXPECT().Call().Return("XResponse", nil)

  yMock := ymock.NewMockY(ctrl)

  got, err := DoComplexThing("inputX", xMock, yMock)

  require.NoError(t, err)
  assert.Equal(t, "want", got)
}

func TestShouldCallYAndFail(t *testing.T) {
  // setup mocks
  ctrl := gomock.NewController(t)
  xMock := xmock.NewMockX(ctrl)

  yMock := ymock.NewMockY(ctrl)
  yMock.EXPECT().Call().Return("YResponse", nil)

  _, err := DoComplexThing("inputY", xMock, yMock)
  assert.EqualError(t, err, "Y failed")
}

این پیچیدگی باعث مشکلات در تغییر، درک و اثبات صحت تست می‌شود.

اگرچه دستورالعمل‌های دقیقی وجود ندارد، وقتی بین استفاده از تست‌های جدولی و تست‌های مجزا برای ورودی‌ها/خروجی‌های متعدد به یک سیستم تصمیم‌گیری می‌کنید، همیشه باید به خوانایی و قابلیت نگهداری فکر کرد.

تست های موازی

تست‌های موازی، مانند برخی از حلقه‌های تخصصی (برای مثال، آن‌هایی که گوروتین‌ها را ایجاد می‌کنند یا ارجاع‌ها را به عنوان بخشی از بدنه حلقه می‌گیرند)، باید دقت کنند که متغیرهای حلقه را به صراحت در محدوده حلقه تخصیص دهند تا اطمینان حاصل شود که مقادیر مورد انتظار را نگه می‌دارند.

tests := []struct{
  give string
  // ...
}{
  // ...
}

for _, tt := range tests {
  tt := tt // for t.Parallel
  t.Run(tt.give, func(t *testing.T) {
    t.Parallel()
    // ...
  })
}

در مثال بالا، به دلیل استفاده از t.Parallel() در زیر حلقه، ما باید یک متغیر tt را در دامنه هر تکرار حلقه تعریف کنیم. اگر این کار را انجام ندهیم، بیشتر یا تمام تست‌ها مقدار غیرمنتظره‌ای برای متغیر tt دریافت خواهند کرد یا مقداری که در حال اجرای آن‌ها تغییر می‌کند.

الگوی Functional Options

گزینه‌های عملکردی (Functional options) الگویی است که در آن یک نوع گزینه (Option) غیرشفاف را اعلام می‌کنید که اطلاعات را در یک ساختار داخلی ثبت می‌کند. شما تعدادی متغیر از این گزینه‌ها را می پذیرید و بر اساس اطلاعات کاملی که توسط گزینه‌ها در ساختار داخلی ثبت شده است، عمل می‌کنید.

از این الگو برای آرگومان‌های اختیاری در متد سازنده‌ها و سایر واسط‌های عمومی (API) که پیش‌بینی می‌کنید نیاز به توسعه آنها دارید، استفاده کنید ه خصوص اگر از قبل سه یا بیشتر آرگومان در این توابع داشته باشید.

بدخوب
// package db

func Open(
  addr string,
  cache bool,
  logger *zap.Logger
) (*Connection, error) {
  // ...
}
// package db

type Option interface {
  // ...
}

func WithCache(c bool) Option {
  // ...
}

func WithLogger(log *zap.Logger) Option {
  // ...
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  // ...
}

پارامترهای کش و لاگر همیشه باید ارائه شوند، حتی اگر کاربر بخواهد از پیش فرض استفاده کند.

db.Open(addr, db.DefaultCache, zap.NewNop())
db.Open(addr, db.DefaultCache, log)
db.Open(addr, false /* cache */, zap.NewNop())
db.Open(addr, false /* cache */, log)

گزینه‌ها (Opptions) فقط در صورت نیاز ارائه می‌شوند.

db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false))
db.Open(
  addr,
  db.WithCache(false),
  db.WithLogger(log),
)

روش پیشنهادی ما برای پیاده‌سازی این الگو استفاده از یک رابط (Interface) به نام Option است که یک متد خصوصی (unexported) را نگه می‌دارد و گزینه‌ها (options) را در یک ساختار (struct) نیز خصوصی ثبت می‌کند.

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

  for _, o := range opts {
    o.apply(&options)
  }

  // ...
}

توجه داشته باشید که روشی برای پیاده‌سازی این الگو با استفاده از توابع بسته (closures) وجود دارد، اما ما باور داریم که الگوی بالا انعطاف بیشتری برای نویسندگان فراهم می‌کند و اشکال‌زدایی و آزمایش آن برای کاربران راحت‌تر است. به طور خاص، این الگو اجازه می‌دهد که گزینه‌ها در تست‌ها و موک‌ها با یکدیگر مقایسه شوند، در مقابل توابع بسته که این امکان در آنها وجود ندارد. علاوه بر این، این الگو به گزینه‌ها امکان پیاده‌سازی رابط‌های دیگر را می‌دهد، از جمله fmt.Stringer که امکان نمایش رشته‌ای خوانا از گزینه‌ها را فراهم می‌کند.

همچنین ببینید،

بررسی و تمیز کردن (linting)

مهمتر از هر چیز، اعمال یک استاندارد یکسان در کل پروژه است، نه استفاده از یک مجموعه خاص از ابزارهای بررسی کد.

توصیه می‌کنیم حداقل از لینترهای زیر استفاده کنید، زیرا فکر می‌کنیم که این ابزارها به شناسایی مشکلات رایج کمک می‌کنند و همچنین یک استاندارد بالا برای کیفیت کد ایجاد می‌کنند بدون اینکه غیرضروری تجویز شوند:

  • errcheck برای اطمینان از رسیدگی به خطاها
  • goimports برای قالب بندی کد و مدیریت واردات
  • golint برای اشاره به اشتباهات رایج استایل
  • govet برای تجزیه و تحلیل کد برای اشتباهات رایج
  • staticcheck برای انجام بررسی های مختلف آنالیز استاتیکی

Lint Runners

ما توصیه می‌کنیم از golangci-lint به عنوان ابزار اصلی برای اجرای عملیات lint در کد Go استفاده کنید، به دلیل عملکرد برتر آن در پروژه‌های بزرگ و قابلیت پیکربندی و استفاده از ابزارهای بررسی کد معتبر بسیاری به صورت همزمان. این مخزن (repo) یک فایل پیکربندی .golangci.yml با ابزارهای بررسی کد پیشنهادی و تنظیمات راهنمایی شده را دارد.

golangci-lint دارای لینترهای مختلفی برای استفاده است. لینترهای فوق به عنوان یک مجموعه پایه توصیه می‌شوند و ما تیم‌ها را تشویق می‌کنیم که هر گونه لینتر اضافی را که برای پروژه‌هایشان منطقی است اضافه کنند.

Releases

No releases published

Packages

No packages published