Awesome
<!-- This file was generated by stitchmd. DO NOT EDIT. To make changes, edit the files in the "src" directory. --> <!-- markdownlint-disable MD033 -->راهنمای سبک و استایل برنامهنویسی شرکت اوبر (Uber) در گولنگ
English
تغییرات
این مخزن به موازات نسخه اصلی آن به صورت بلادرنگ آپدیت خواهد شد. همچنین میتوانید لیست کامل تغییرات را در فایل CHANGELOG.md مشاهده کنید.
نسخه
- نسخه فعلی: 2023-05-09
- آخرین کامیت: 174#
[!NOTE]
در صورت مشاهده هرگونه مشکل یا داشتن پیشنهادی برای بهبود محتوای این پروژه، شما میتوانید با مشارکت در آن به بهبود آن کمک کنید. قطعاً مشارکت جمعی میتواند به پروژه کمک کند تا به سطحی بالاتر از کیفیت و کارایی برسد
فهرست مطالب
- مقدمه
- راهنماها
- ارجاع به رابطها (Pointers to Interfaces)
- انطباقپذیری رابطها
- گیرندهها و رابطها (Recievers and Interfaces)
- مقدار صفر (zero-value) Mutexها معتبر هستند
- کپی کردن بخشهای مشخص از Sliceها و Mapها
- به تعویق انداختن (Defer) پاکسازی منابع
- اندازه کانال (Channel) یک یا هیچ است
- ثابتهای نامگذاری شده (Enums) را از یک شمارهگذاری کنید
- استفاده از
"Time"
برای مدیریت زمان - خطاها (Errors)
- مدیریت نوع ادعای (Type Assertion) شکستها
- از ایجاد Panic جلوگیری کنید (Don't Panic)
- از پکیج "go.uber.org/atomic" استفاده کنید
- از متغیرهای سراسری تغییرپذیر (Mutable Globals) خودداری کنید
- از جاسازی نوعها (Embedding Types) در ساختارهای عمومی خودداری کنید
- از استفاده از نامهای داخلی (Buit-In) خودداری کنید
- از تابع
()init
استفاده نکنید - خروج فقط در تابع اصلی (Main)
- از برچسبهای فیلد در ساختارهای مارشال شده (marshaled) استفاده کنید
- گوروتینها را به حال خودشان (بدون نظارت) رها نکنید
- کارایی (Performance)
- استایل (style)
- از خطوط بیش از حد طولانی خودداری کنید
- یکپارچگی را رعایت کنید
- تعاریف مشابه را گروهبندی کنید
- مرتبسازی گروهی واردات (imports)
- نامگذاری بستهها (Package Names)
- نامگذاری توابع (Function Names)
- نام مستعار واردات (Import)
- گروهبندی و مرتبسازی توابع
- تورفتگی (Nesting) را کاهش دهید
- اجتناب از Elseهای غیر ضروری
- تعاریف متغیرهای سطح بالا
- از پیشوند
"_"
برای متغیرهای خصوصی (Unexported) استفاده کنید - جاسازی (Embedding) در ساختارها
- تعاریف متغیرهای محلی
- خود
nil
یک برشslice
معتبر است - کاهش دامنه (scope) متغیرها
- از پارامترهای بینام(Naked Parameters) خودداری کنید
- استفاده از
Raw String Literals
برای جلوگیری از Escape شدن کاراکترها - مقداردهی اولیه ساختارها (structs)
- مقداردهی اولیه Mapها
- قالببندی رشتهها (strings) خارج از تابع
Printf
- نامگذاری توابع به سبک
Printf
- الگوها
- بررسی و تمیز کردن (linting)
مقدمه
استایلها، قراردادهایی هستند که کد ما را کنترل میکنند. شاید کلمه یا اصطلاح استایل درست نباشد، زیرا این قوانین خیلی فراتر از فقط قالببندی فایل منبع هستند - gofmt این کار را برای ما انجام میدهد.
هدف اصلی این راهنما مدیریت پیچیدگیها با توضیح دقیق "بایدها و نبایدهای" نوشتن کد Go در Uber است. این قوانین برای مدیریت راحتتر کد منبع است و همچنین به مهندسان اجازه می دهد تا از ویژگیهای زبان Go به طور موثر استفاده کنند.
این راهنما در اصل توسط Prashant Varanasi و Simon Newton به عنوان راهی برای برای آموزش سریع همکاران به استفاده از Go ایجاد شده است. در طول سالها، بر اساس بازخوردهای دیگران، این راهنما تصحیح و بهروزرسانی شده است.
این راهنما اصول و قوانین معمولی در نوشتن کد Go در Uber را شامل میشود. بسیاری از این موارد، رهنمودهای عمومی برای Go هستند، در حالی که برخی از آنها از منابع خارجی نشات می گیرند:
هدف ما این است که نمونه کدهای ما برای آخرین نسخههای اخیر منتشر شده Go releases تنظیم شوند.
همه کدها هنگام اجرا با استفاده از golint
و go vet
باید بدون خطا باشند. ما پیشنهاد میکنیم ویرایشگر خود را بهصورت زیر تنظیم کنید:
- Run
goimports
on save - Run
golint
andgo 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) دارای دو فیلد است:
- یک اشارهگر (pointer) به برخی از اطلاعات یک نوع خاص که شما میتوانید آن را به عنوان یک "type" در نظر بگیرید.
- اشارهگر داده. اگر داده ذخیره شده یک اشارهگر باشد، به صورت مستقیم ذخیره میشود. اگر داده ذخیره شده یک مقدار باشد، آنگاه یک اشارهگر به مقدار ذخیره شده میشود.
اگر میخواهید متدهای رابط(interface)، تغییراتی روی داده زیرین اعمال کنند، باید از یک اشارهگر استفاده کنید.
انطباق پذیری رابط ها
در صورت لزوم، مطابقت رابط را در زمان کامپایل بررسی کنید. این شامل:
- تایپهای Exported که برای پیاده سازی رابطهای خاص به عنوان بخشی از قرارداد API مورد نیاز هستند
- تایپهای Exported یا unexported که بخشی از مجموعهای از تایپها هستند که همگی یک رابط مشابهی را پیادهسازی میکنند
- سایر موارد دیگری که در آن نقض یک رابط باعث نقض قراردادها میشود
type Handler struct {
// ...
}
func (h *Handler) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
// ...
}
</td><td>
type Handler struct {
// ...
}
var _ http.Handler = (*Handler)(nil)
func (h *Handler) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
// ...
}
</td></tr>
</tbody></table>
دستور 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()
</td><td>
var mu sync.Mutex
mu.Lock()
</td></tr>
</tbody></table>
اگر از اشارهگر به یک ساختار (struct) استفاده میکنید، mutex باید به عنوان یک فیلد غیر اشارهگری درون آن قرار گیرد. حتی اگر ساختار (struct) به صورت (non-exported) استفاده شود، نباید mutex را به طور مستقیم درون ساختار جاسازی (embedded) کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>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]
}
</td><td>
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]
}
</td></tr>
<tr><td>
فیلد Mutex
و متدهای Lock
and Unlock
ناخواسته بخشی از API صادر شده SMap
هستند.
میوتکس (mutex) و متدهای آن جزئیات پیادهسازی SMap
هستند که از تماسگیرندگان آن مخفی میمانند.
کپی کردن بخش های مشخص از Sliceها و Mapها
برشها (slices) و نقشهها (maps) شامل اشارهگرهایی به داده زیرین خود هستند، بنابراین در مواردی که نیاز به کپی آنها دارید، مراقب باشید.
دریافت Slices و Maps
به خاطر داشته باشید که اگر شما یک ارجاع به Map یا Slice که به عنوان ورودی دریافت کردهاید نگه دارید، کاربران ممکن است تغییراتی در آنها ایجاد کنند.
<table> <thead><tr><th>بد</th> <th>خوب</th></tr></thead> <tbody> <tr> <td>func (d *Driver) SetTrips(trips []Trip) {
d.trips = trips
}
trips := ...
d1.SetTrips(trips)
// آیا شما منظورتان از تغییر d1.trips بود؟
trips[0] = ...
</td>
<td>
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] = ...
</td>
</tr>
</tbody>
</table>
برگرداندن Slices و Maps
به طور مشابه، مراقب تغییراتی باشید که کاربران در Mapها یا Sliceها اعمال میکنند و وضعیت داخلی آنها را فاش میکنند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>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()
</td><td>
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()
</td></tr>
</tbody></table>
به تعویق انداختن (Defer) پاکسازی منابع
از defer برای پاکسازی منابعی مانند فایلها و قفلها استفاده کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>p.Lock()
if p.count < 10 {
p.Unlock()
return p.count
}
p.count++
newCount := p.count
p.Unlock()
return newCount
// به دلیل وجود return های متعدد
// ممکن است آزاد کردن قفلها را فراموش کنید
</td><td>
p.Lock()
defer p.Unlock()
if p.count < 10 {
return p.count
}
p.count++
return p.count
// خیلی خواناتر
</td></tr>
</tbody></table>
استفاده از defer
سربار خیلی کمی دارد و فقط در صورتی باید از آن اجتناب کرد که بتوانید اثبات کنید زمان اجرای تابع شما در مرتبه نانوثانیه قرار دارد. از نظر خوانایی کد، استفاده از defer
ارزشمند است. این موضوع به خصوص برای متدهای بزرگتر که دارای عملیاتهای پیچیدهتری هستند و محاسبات دیگری در آنها مهمتر از defer
هستند، صدق میکند.
اندازه کانال (Channel) یک یا هیچ است
کانالها به طور معمول باید دارای اندازه یک یا بدون بافر باشند. به طور پیشفرض، کانالها بدون بافر و با اندازه صفر هستند. هر اندازه دیگری باید با دقت بررسی شود. در نظر داشته باشید که چگونه اندازه تعیین میشود، چه چیزی مانع پر شدن کانال تحت بار میشود و باعث مسدود شدن Writerها میشود و با وجود این اتفاقات چه چیزی رخ میدهد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// باید برای هر کسی کافی باشد!
c := make(chan int, 64)
</td><td>
// اندازه یک
c := make(chan int, 1) // یا
// کانال بدون بافر، اندازه صفر
c := make(chan int)
</td></tr>
</tbody></table>
ثابت های نام گذاری شده (Enums) را از یک شماره گذاری کنید
روش استاندارد برای معرفی تعداد محدودی (enumeration) در Go، اعلام یک نوع سفارشی (custom type) و یک گروه const
با iota
است. از آنجا که متغیرها معمولاً مقدار پیشفرض 0 دارند، بهتر است enums خود را با یک مقدار غیرصفر شروع کنید.
type Operation int
const (
Add Operation = iota
Subtract
Multiply
)
// Add=0, Subtract=1, Multiply=2
</td><td>
type Operation int
const (
Add Operation = iota + 1
Subtract
Multiply
)
// Add=1, Subtract=2, Multiply=3
</td></tr>
</tbody></table>
مواردی وجود دارد که استفاده از مقدار صفر منطقی است، برای مثال زمانی که حالت صفر رفتار پیشفرض مطلوب است.
type LogOutput int
const (
LogToStdout LogOutput = iota
LogToFile
LogToRemote
)
// LogToStdout=0, LogToFile=1, LogToRemote=2
<!-- TODO: section on String methods for enums -->
استفاده از "Time"
برای مدیریت زمان
زمان پیچیده است. مفروضات نادرستی که اغلب در مورد زمان انجام میشود شامل موارد زیر است.
- یک روز 24 ساعت دارد
- یک ساعت 60 دقیقه دارد
- یک هفته 7 روز دارد
- یک سال 365 روز دارد
- و موارد دیگر
به عنوان مثال، 1 به این معنی است که افزودن 24 ساعت به یک لحظه از زمان، همیشه یک روز تقویمی جدید ایجاد نمیکند.
بنابراین، هنگام برخورد با زمان، همیشه از پکیج "زمان"
استفاده کنید زیرا به مقابله با این فرضیات نادرست به شیوه ای مطمئنتر و دقیقتر کمک میکند.
از time.Time
برای نمایش لحظات زمانی استفاده کنید.
هنگام کار با لحظات زمانی از نوع time.Time
و متدهای مربوط به time.Time
برای مقایسه، افزودن، یا کاستن زمان استفاده کنید.
func isActive(now, start, stop int) bool {
return start <= now && now < stop
}
</td><td>
func isActive(now, start, stop time.Time) bool {
return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}
</td></tr>
</tbody></table>
از time.Duration
برای بازههای زمانی استفاده کنید.
هنگام کار با بازههای زمانی از نوع time.Duration
استفاده کنید.
func poll(delay int) {
for {
// ...
time.Sleep(time.Duration(delay) * time.Millisecond)
}
}
poll(10) // ثانیه بود یا میلیثانیه؟
</td><td>
func poll(delay time.Duration) {
for {
// ...
time.Sleep(delay)
}
}
poll(10*time.Second)
</td></tr>
</tbody></table>
به مثال افزودن 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"`
}
</td><td>
// {"intervalMillis": 2000}
type Config struct {
IntervalMillis int `json:"intervalMillis"`
}
</td></tr>
</tbody></table>
زمانی که در تعامل با سیستمهای خارجی امکان استفاده از نوع 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")
}
</td><td>
// 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")
}
}
</td></tr>
</tbody></table>
برای خطای با رشته پویا (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")
}
</td><td>
// 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, ¬Found) {
// handle the error
} else {
panic("unknown error")
}
}
</td></tr>
</tbody></table>
توجه داشته باشید که اگر متغیرها یا انواع خطا را از یک پکیج (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 را مختصر نگه دارید:
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>s, err := store.New()
if err != nil {
return fmt.Errorf(
"failed to create new store: %w", err)
}
</td><td>
s, err := store.New()
if err != nil {
return fmt.Errorf(
"new store: %w", err)
}
</td></tr><tr><td>
failed to x: failed to y: failed to create new store: the error
</td><td>
x: y: new store: the error
</td></tr>
</tbody></table>
با این حال، هنگامی که خطا به سیستم دیگری ارسال میشود، باید مشخص باشد که پیام یک خطا است (به عنوان مثال یک برچسب 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، به توجه به شرایط.
صرف نظر از نحوه برخورد تماس گیرنده با خطاها، معمولاً باید هر خطا را فقط یک بار مدیریت کند. به عنوان مثال، تماس گیرنده نباید خطا را ثبت کند و سپس آن را برگرداند، زیرا تماس گیرنده آن نیز ممکن است خطا را مدیریت کند.
به عنوان مثال موارد زیر را در نظر بگیرید:
<table> <thead><tr><th>توضیحات</th><th>کد</th></tr></thead> <tbody> <tr><td>بد: خطا را ثبت کنید و آن را برگردانید
تماس گیرندگان در پشته ممکن است اقدامات مشابهی در مورد این خطا انجام دهند. انجام این کار باعث تولید مقدار زیادی اطلاعات ناکارآمد در گزارشهای برنامه میشود که ارزش چندانی نخواهد داشت.
</td><td>u, err := getUser(id)
if err != nil {
// BAD: See description
log.Printf("Could not get user %q: %v", id, err)
return err
}
</td></tr>
<tr><td>
خوب: خطا را Wrap کنید و برگردانید.
تماس گیرندگان بالاتر از پشته، خطا را کنترل خواهند کرد.
استفاده از %w
تضمین میکند که میتوانند خطا را با errors.Is
یا errors.As
مطابقت دهند.
u, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
</td></tr>
<tr><td>
خوب: ابتدا خطا را ثبت کنید (لاگ کنید) و سپس به آرامی و به صورت کنترل شده به وضعیت عادی یا نرمال خود بازگردید
اگر یک عملیات خاصی در برنامه نیاز به اجرا ندارد و میتواند به صورت کمکیفیتتری انجام شود، میتوانیم از ابزارها و راهکارهایی استفاده کنیم تا از خطاها بازیابی کنیم و تجربه کاربران را بدون وقوع شکست بهبود بخشیم.
</td><td>if err := emitMetrics(); err != nil {
// Failure to write metrics should not
// break the application.
log.Printf("Could not emit metrics: %v", err)
}
</td></tr>
<tr><td>
خوب: ابتدا خطا را تشخیص دهید (تطابق دهید) و سپس به آرامی و به صورت کنترل شده به وضعیت عادی یا نرمال خود بازگردید
اگر فراخواننده (caller) در قرارداد خود یک خطای خاص تعریف کرده باشد و خرابی قابل بازیابی باشد، در مورد آن خطا تطابق (match) کنید و به صورت کنترل شده آن را به حالت عادی بازگردانید. برای موارد دیگر، خطا را پوشش دهید (wrap) و آن را بازگردانید.
سایر خطاها توسط تماس گیرندگان بالاتر در پشته رسیدگی میشود.
</td><td>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)
}
}
</td></tr>
</tbody></table>
مدیریت نوع ادعای (Type Assertion) شکست ها
مقدار برگشتی بدست آمده از type assertion روی یک تایپ نادرست panic خواهد شد. بنابراین همیشه از اصطلاح "comma ok" استفاده کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>t := i.(string)
</td><td>
t, ok := i.(string)
if !ok {
// به خوبی خطا را مدیریت کنید
}
</td></tr>
</tbody></table>
<!-- TODO: There are a few situations where the single assignment form is
fine. -->
از ایجاد Panic جلوگیری کنید (Don't Panic)
کدهایی که در محیط تولید (Production) اجرا میشوند، باید از وقوع (Panic) جلوگیری کنند. panicها عامل اصلی ایجاد شکستهای متوالی(آبشاری) هستند. اگر خطایی رخ دهد، تابع باید یک خطای مناسب را برگردانده و به فراخواننده (caller) اجازه دهد تا تصمیم بگیرد که چگونه با آن برخورد کند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>func run(args []string) {
if len(args) == 0 {
panic("an argument is required")
}
// ...
}
func main() {
run(os.Args[1:])
}
</td><td>
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)
}
}
</td></tr>
</tbody></table>
استفاده از 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")
}
</td><td>
// func TestFoo(t *testing.T)
f, err := os.CreateTemp("", "test")
if err != nil {
t.Fatal("failed to set up test")
}
</td></tr>
</tbody></table>
از پکیج 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!
}
</td><td>
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()
}
</td></tr>
</tbody></table>
از متغیرهای سراسری تغییرپذیر (Mutable Globals) خودداری کنید
از تزریق وابستگی (Dependency Injection) بجای تغییر متغیرهای سراسری استفاده کنید. این مورد روی اشارهگرهای تابع (function pointers) و همچنین برای انواع مقادیر دیگر نیز اعمال میشود.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// sign.go
var _timeNow = time.Now
func sign(msg string) string {
now := _timeNow()
return signWithTime(msg, now)
}
</td><td>
// 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)
}
</td></tr>
<tr><td>
// 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))
}
</td><td>
// sign_test.go
func TestSigner(t *testing.T) {
s := newSigner()
s.now = func() time.Time {
return someFixedTime
}
assert.Equal(t, want, s.Sign(give))
}
</td></tr>
</tbody></table>
از جاسازی نوع ها (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) {
// ...
}
<table>
<thead><tr><th>بد</th><th>خوب</th></tr></thead>
<tbody>
<tr><td>
// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
*AbstractList
}
</td><td>
// 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)
}
</td></tr>
</tbody></table>
زبان Go امکان type embedding را به عنوان یک توافق بین ارثبری و ترکیب فراهم میکند. نوع بیرونی (outer type) نسخههای ضمنی از متدهای نوع تعبیهشده را به طور ضمنی به ارث میبرد و این متدها به طور پیشفرض به متد مشابه در نمونه تعبیهشده ارجاع داده میشوند.
همچنین، ساختار (struct) فیلدی با همان نام نوع تعبیه شده را دریافت می کند. بنابراین، اگر نوع تعبیهشده عمومی باشد، فیلد عمومی است. برای حفظ توانایی کار کردن کدهای قدیمی با نسخههای جدید، هر نسخه بعدی از نوع خارجی باید نوع تعبیه شده را حفظ کند.
نیاز به تعبیه (embedding) نوعها به ندرت پیش میآید. این یک روش مفید است که به شما کمک میکند از نوشتن متدهای دستوری بلند و پیچیده جلوگیری کنید.
حتی اگر یک رابط (interface) AbstractList سازگار را جایگزین ساختار (struct) کنید، به توسعهدهنده امکان بیشتری برای تغییر در آینده ارائه میدهد، اما همچنان جزئیات استفاده از یک پیادهسازی انتزاعی برای concrete لیستها را فاش میکند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// AbstractList یک پیاده سازی کلی از لیست های موجودیت های مختلف است.
type AbstractList interface {
Add(Entity)
Remove(Entity)
}
// ConcreteList لیستی از موجودیت ها است.
type ConcreteList struct {
AbstractList
}
</td><td>
// 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)
}
</td></tr>
</tbody></table>
استفاده از ساختارهای تعبیه شده (embedded struct) یا رابطهای تعبیه شده (embedded interface)، تکامل و توسعه Typeها را محدود می کند.
- افزودن متدها به یک رابط تعبیهشده تغییرات مخربی ایجاد میکند.
- حذف متدها از یک ساختار تعبیهشده نیز تغییر مخربی محسوب میشود.
- حذف نوع تعبیهشده همچنین به عنوان یک تغییر مخرب در نظر گرفته میشود.
- حتی جایگزین کردن نوع تعبیهشده با یک نوع جایگزین که همان رابط مشابه را پیادهسازی میکند، تغییر مخربی به حساب میآید.
اگرچه نوشتن این متدها (متدهای تعبیهشده) کمی زمانبر است، اما تلاش اضافی که برای این کار صرف میشود، باعث میشود جزئیات پیادهسازی متدها پنهان شوند. همچنین، این کار فرصتهای بیشتری برای تغییر در آینده فراهم میکند و همچنین این کار به بهبود پایداری و قابلیت تغییر بیشتر کد کمک میکند و به از بین بردن انحرافات و پیچیدگیهای غیر ضروری در مستندات کمک میکند.
از استفاده از نام های داخلی (Buit-In) خودداری کنید
مشخصات زبان Go چندین شناسه داخلی و شناسههای از پیش اعلام شده را مشخص می کند که نباید در پروژه های Go استفاده شوند.
بسته به زمینه (context)، استفاده مجدد از این شناسهها به عنوان نام، شناسه اصلی را در محدوده فعلی (یا هر محدوده تودرتو) پنهان میکند یا کد را مبهم میکند. در بهترین حالت، کامپایلر یک خطا ایجاد میکند؛ در بدترین حالت، چنین کدی ممکن است خطاهایی را ایجاد کند که بازیابی آنها دشوار است.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>var error string
// استفاده از نام `error` به عنوان یک متغیر یا شناسه، باعث ایجاد سایه روی نام داخلی `error` میشود.
// یا
func handleErrorMessage(error string) {
// استفاده از نام `error` به عنوان یک متغیر یا شناسه، باعث ایجاد سایه روی نام داخلی `error` میشود.
}
</td><td>
var errorMessage string
// در اینجا `error` به عنوان یک متغیر یا شناسه داخلی در نظر گرفته میشود
// or
func handleErrorMessage(msg string) {
// در اینجا `error` به عنوان یک متغیر یا شناسه داخلی در نظر گرفته میشود
}
</td></tr>
<tr><td>
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
}
</td><td>
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
}
</td></tr>
</tbody></table>
توجه داشته باشید که کامپایلر هنگام استفاده از شناسههای پیشتعریف شده خطا ایجاد نمی کند، اما ابزارهایی مانند go vet
به درستی به مشکلات ضمنی در این موارد و موارد دیگر اشاره می کنند.
از تابع ()init
استفاده نکنید
در صورت امکان، از ()init
پرهیز کنید. وقتی لازم به استفاده از تابع ()init
هستید، کد باید تلاش کند:
- بدون توجه به محیط برنامه یا نحوه فراخوانی، کاملاً قطعی عمل کند.
- سعی کنید از ایجاد وابستگی به ترتیب اجرا یا اثرات جانبی مربوط به توابع
()init
دیگر خودداری کنید.هرچند ترتیب اجرای توابع()init
به خوبی شناخته شده است، اما کد ممکن است تغییر کند و این وابستگیها میتوانند منجر به ناپایداری و خطاهای پنهان شوند. - از دسترسی یا دستکاری وضعیت سراسری یا محیطی مانند اطلاعات ماشین، متغیرهای محیطی، دایرکتوری کاری، آرگومانها/ورودیهای برنامه و غیره پرهیز کند.
- از عملیات ورود/خروج (I/O) مانند عملیات فایلسیستم، شبکه و تماسهای سیستمی پرهیز کند.
کدی که نمیتواند این موارد را انجام دهد، احتمالاً بهتر است به عنوان یک تابع کمکی برای فراخوانی در ()main
(یا در جای دیگر در چرخه عمر برنامه) قرار گیرد یا به عنوان بخشی از خود ()main
نوشته شود. به خصوص کتابخانههایی که برای استفاده در برنامههای دیگر طراحی شدهاند، باید مراقبتهای خاصی را برای تضمین قطعیت کامل داشته باشند و از "جادوی init
" پرهیز کنند.
type Foo struct {
// ...
}
var _defaultFoo Foo
func init() {
_defaultFoo = Foo{
// ...
}
}
</td><td>
var _defaultFoo = Foo{
// ...
}
// یا برای تست پذیری بهتر:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
return Foo{
// ...
}
}
</td></tr>
<tr><td>
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)
}
</td><td>
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
}
</td></tr>
</tbody></table>
با توجه به موارد فوق، شرایطی وجود دارد که ممکن است ()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)
}
</td><td>
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
}
</td></tr>
</tbody></table>
در اصل: برنامههایی که دارای تعدادی تابع هستند که دارای توابع خروج فوری هستند، چندین مسئله را با خود به همراه دارند:
- جریان کنترل ناپیدا: هر تابعی ممکن است باعث خروج برنامه شود، به همین دلیل تبدیل به یک موضوع سخت برای استدلال در مورد کنترل جریان میشود.
- دشواری در تست: توابعی که برنامه را خارج میکنند باعث میشوند فرآیند تست متوقف شود. این باعث میشود که تست کردن این توابع دشوار شود و منجر به خطر از دست دادن تستهای دیگری که هنوز توسط
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)
}
// ...
}
</td><td>
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
}
// ...
}
</td></tr>
</tbody></table>
مثال بالا از 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 یا سایر فرمتهایی که از نامگذاری بر اساس تگها پشتیبانی میکنند، باید با تگ مربوطه مشخص شود.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>type Stock struct {
Price int
Name string
}
bytes, err := json.Marshal(Stock{
Price: 137,
Name: "UBER",
})
</td><td>
type Stock struct {
Price int `json:"price"`
Name string `json:"name"`
// ایمن برای تغییر نام به نماد.
}
bytes, err := json.Marshal(Stock{
Price: 137,
Name: "UBER",
})
</td></tr>
</tbody></table>
گوروتینها سبک هستند، اما رایگان نیستند: حداقل هزینههایی را برای استفاده از حافظه برای پشته آنها و زمان CPU برای زمانبندی آنها دارند. این هزینهها در موارد معمولی کمترین تأثیر را دارند، اما زمانی که تعداد زیادی گوروتین بدون مدیریت صحیح ایجاد میشوند، میتوانند به مشکلات عملکردی بزرگی منجر شوند. همچنین، گوروتینهایی که مدیریت زمانهای چرخه حیات مشخصی ندارند، میتوانند به مشکلات دیگری نیز منجر شوند، مثل جلوگیری از جمعآوری زبالهها (garbage collected) و نگهداشتن منابعی که دیگر استفاده نمیشوند.
گوروتین ها را به حال خودشان (بدون نظارت) رها نکنید
گوروتینها سبک هستند، اما رایگان نیستند: حداقل هزینههایی را برای استفاده از حافظه برای پشته آنها و زمان CPU برای زمانبندی آنها دارند. این هزینهها در موارد معمولی کمترین تأثیر را دارند، اما زمانی که تعداد زیادی گوروتین بدون مدیریت صحیح ایجاد میشوند، میتوانند به مشکلات عملکردی بزرگی منجر شوند. همچنین، گوروتینهایی که بدون مدیریت زمانهای چرخه حیات مشخصی ایجاد میشوند، میتوانند به مشکلات دیگری نیز منجر شوند، مثل جلوگیری از جمعآوری زبالهها (garbage collected) و نگهداشتن منابعی که دیگر استفاده نمیشوند.
بنابراین، از لو رفتن (leak) گوروتینها در کد تولیدی (production code) جلوگیری کنید. برای تست نشتی گوروتین داخل پکیجهایی که ممکن است گوروتین ایجاد کنند، از go.uber.org/goleak استفاده کنید.
بطور کلی، هر گوروتین باید:
- یک زمان پیشبینیشده برای متوقف شدن داشته باشد؛ یا
- باید یک راه برای اعلام به گوروتین وجود داشته باشد که باید متوقف شود.
در هر دو مورد، باید یک روش وجود داشته باشد تا کد بلاک شده و منتظر اتمام گوروتین شود.
برای مثال:
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>go func() {
for {
flush()
time.Sleep(delay)
}
}()
</td><td>
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 // و صبر کنید تا خارج شود
</td></tr>
<tr><td>
هیچ راهی برای متوقف کردن این گوروتین وجود ندارد. گوروتین تا زمانی که برنامه خارج شود اجرا می شود.
</td><td>این گوروتین را می توان با 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 {
// ...
}
}
</td><td>
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
}
</td></tr>
<tr><td>
زمانی که کاربر این بسته (package) را (export) میکند، یک گوروتین پسزمینه بدون شرطی ایجاد میشود. کاربر هیچ کنترلی بر روی گوروتین ندارد و هیچ وسیلهای برای متوقف کردن آن وجود ندارد.
</td><td>گوروتین، 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())
}
</td><td>
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
}
</td></tr>
<tr><td>
BenchmarkFmtSprint-4 143 ns/op 2 allocs/op
</td><td>
BenchmarkStrconv-4 64.2 ns/op 1 allocs/op
</td></tr>
</tbody></table>
از تبدیل رشته به بایت (string-to-byte) خودداری کنید
به طور مکرر برشهای بایت (byte slices) را از stringهای ثابت ایجاد نکنید. بجای اینکار، یک تبدیل انجام دهید و نتیجه را ثبت کنید.
<table> <thead><tr><th>Bad</th><th>Good</th></tr></thead> <tbody> <tr><td>for i := 0; i < b.N; i++ {
w.Write([]byte("Hello world"))
}
</td><td>
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data)
}
</td></tr>
<tr><td>
BenchmarkBad-4 50000000 22.2 ns/op
</td><td>
BenchmarkGood-4 500000000 3.25 ns/op
</td></tr>
</tbody></table>
ترجیحا ظرفیت کانتینر (container) را مشخص کنید
تا جایی که امکان دارد ظرفیت کانتینر را مشخص کنید تا حافظه از قبل برای کانتینر تخصیص داده شود. این امر تخصیص های بعدی (با کپی و تغییر اندازه ظرف) را هنگام افزودن عناصر به حداقل می رساند.
تعیین حداکثر ظرفیت ممکن Map
در صورت امکان، هنگام مقداردهی اولیه Mapها با ()make
اندازه ظرفیت آن را مشخص کنید.
make(map[T1]T2, hint)
مشخص کردن ظرفیت به ()make باعث ایجاد Map در زمان مقداردهی اولیه میشود، که در صورت اضافه شدن عناصر به Map، از تخصیص مجدد حافظه برای Map جلوگیری میکند.
در واقعیت، تعیین ظرفیت Map با استفاده از تابع ()make نمیتواند به صورت دقیق و کامل تعداد دقیق buckets مورد نیاز برای یک hashmap را پیشبینی کند. به جای اینکه به صورت کامل پیشبینی شده باشد، این تعیین ظرفیت تقریبا buckets مورد نیاز برای hashmap را ارائه میدهد. به عبارت دیگر، حتی با تعیین یک ظرفیت خاص، ممکن است در هنگام افزودن عناصر به Map، تخصیصها (allocation) انجام شود.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>m := make(map[string]os.FileInfo)
files, _ := os.ReadDir("./files")
for _, f := range files {
m[f.Name()] = f
}
</td><td>
files, _ := os.ReadDir("./files")
m := make(map[string]os.DirEntry, len(files))
for _, f := range files {
m[f.Name()] = f
}
</td></tr>
<tr><td>
متغیر 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)
}
}
</td><td>
for n := 0; n < b.N; n++ {
data := make([]int, 0, size)
for k := 0; k < size; k++{
data = append(data, k)
}
}
</td></tr>
<tr><td>
BenchmarkBad-4 100000000 2.48s
</td><td>
BenchmarkGood-4 100000000 0.21s
</td></tr>
</tbody></table>
استایل (style)
از خطوط بیش از حد طولانی خودداری کنید
از خطوط کدی که خوانندگان را ملزم به اسکرول افقی یا چرخاندن بیش از حد سر خود می کند اجتناب کنید.
ما محدودیت طول خط نرم 99 کاراکتر را توصیه می کنیم. نویسندگان باید قبل از رسیدن به این حد، خطوط را wrap کنند، اما این یک محدودیت دقیق نیست. اجازه داده شده است که کد این محدودیت را تجاوز کند.
یکپارچگی را رعایت کنید
برخی از معیارهای ذکر شده در این مقاله، ارزیابی های عینی، بر اساس موقعیت یا سناریو، زمینه (context)، یا قضاوت های ذهنی هستند.
مهمتر از همه اینا، پیوستگی را حفظ کنید.
کد یکنواخت و یکدست راحتتر ویرایش میشود، منطقیتر است، نیاز به تفکر کمتری دارد، و همچنین راحتتر میتوان آن را بهروز کرد و رفع اشکالها در آن آسانتر است.
به عبارت دیگر، داشتن چندین سبک مختلف کدنویسی در یک پایگاه کد میتواند منجر به هزینههای سربار تعمیر و نگهداری، عدم انسجام و عدم تطابق در استایلها یا نگارش کد میشود در نهایت همه اینها مستقیماً منجر به کاهش سرعت، بررسی کدهای پیچیده و افزایش تعداد اشکال میشود.
هنگام اعمال این استانداردها در یک codebase، توصیه میشود که تغییرات در سطح پکیج (یا بزرگتر) اعمال شود، با اجرای این تغییرات در سطح زیربسته (sub-package) میتواند نگرانیهای فوق را با معرفی چندین سبک در یک کد نقض کند.
تعاریف مشابه را گروه بندی کنید
زبان Go از گروهبندی اعلانهای مشابه پشتیبانی میکند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>import "a"
import "b"
</td><td>
import (
"a"
"b"
)
</td></tr>
</tbody></table>
همین امر در مورد ثابتها، متغیرها و اعلان تایپها صدق میکند:
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>
const a = 1
const b = 2
var a = 1
var b = 2
type Area float64
type Volume float64
</td><td>
const (
a = 1
b = 2
)
var (
a = 1
b = 2
)
type (
Area float64
Volume float64
)
</td></tr>
</tbody></table>
فقط اعلانهای مرتبط را گروهبندی کنید. اعلانهای غیرمرتبط را گروهبندی نکنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>type Operation int
const (
Add Operation = iota + 1
Subtract
Multiply
EnvVar = "MY_ENV"
)
</td><td>
type Operation int
const (
Add Operation = iota + 1
Subtract
Multiply
)
const EnvVar = "MY_ENV"
</td></tr>
</tbody></table>
هیچ محدودیتی برای استفاده از گروهها وجود ندارد، به عنوان مثال: میتوانید از آنها در داخل توابع استفاده کنید:
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>func f() string {
red := color.New(0xff0000)
green := color.New(0x00ff00)
blue := color.New(0x0000ff)
// ...
}
</td><td>
func f() string {
var (
red = color.New(0xff0000)
green = color.New(0x00ff00)
blue = color.New(0x0000ff)
)
// ...
}
</td></tr>
</tbody></table>
استثنا: اعلانهای متغیر (مخصوصاً آنهایی که درون توابع هستند) در صورت مجاورت با متغیرهای دیگر باید با هم گروهبندی شوند. این کار را برای متغیرهای اعلام شده با هم انجام دهید، حتی اگر نامرتبط باشند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>func (c *client) request() {
caller := c.name
format := "json"
timeout := 5*time.Second
var err error
// ...
}
</td><td>
func (c *client) request() {
var (
caller = c.name
format = "json"
timeout = 5*time.Second
err error
)
// ...
}
</td></tr>
</tbody></table>
مرتب سازی گروهی واردات (imports)
واردات باید به دو دسته تقسیم شود:
- کتابخانه استاندارد
- سایر کتابخانه ها این گروه بندی است که توسط goimports به طور پیش فرض اعمال می شود.
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
)
</td><td>
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
)
</td></tr>
</tbody></table>
نام گذاری بسته ها (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 وجود داشته باشد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>import (
"fmt"
"os"
nettrace "golang.net/x/trace"
)
</td><td>
import (
"fmt"
"os"
"runtime/trace"
nettrace "golang.net/x/trace"
)
</td></tr>
</tbody></table>
گروه بندی و مرتب سازی توابع
- توابع باید بر اساس تقریبی فراخوانیشان مرتب شوند(منظور این است که توابعی که بیشترین احتمال برای فراخوانی آنها وجود دارد در ابتدا آورده میشوند و توابعی که کمترین احتمال فراخوانی رو دارند در انتها قرار میگیرند).
- توابع موجود در یک فایل باید بر اساس گیرنده (receiver) گروه بندی شوند(منظور این است که توابعی که بر روی یک نوع خاص عمل میکنند، در یک قسمت مشخص از کد قرار میگیرند. این کار به ترتیب و منظم شدن کدها کمک میکند و به توسعه دهندگان کمک میکند تا توابع مرتبط با هم را به راحتی پیدا کنند).
بنابراین، توابع صادرشده (exported) باید بعد از تعریفهای struct
, const
, var
در ابتدای فایل ظاهر شوند.
توابعی که با ()newXYZ
/()NewXYZ
شروع میشوند، ممکن است بعد از تعریف نوع (type) قبل از باقی متدهای دریافتکننده (receiver) ظاهر شوند.
از آنجایی که توابع توسط گیرنده (receiver) گروهبندی میشوند، توابع utility باید در انتهای فایل ظاهر شوند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>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{}
}
</td><td>
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 {...}
</td></tr>
</tbody></table>
تورفتگی (Nesting) را کاهش دهید
کد باید سعی کند تورفتگی (nesting) را به حداقل برساند. برای این کار، ابتدا موارد خطا یا شرایط خاص را بررسی و پردازش کند و در صورت لزوم به سرعت از تابع خارج شود یا به مرحله بعد بروند. همچنین باید تلاش کند تا تعداد کدهایی که به چندین سطح تورفتگی وارد میشوند را کاهش دهد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>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)
}
}
</td><td>
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()
}
</td></tr>
</tbody></table>
اجتناب از Elseهای غیر ضروری
اگر یک متغیر در هر دو شاخه (شرط true و شرط false) یک دستور if تنظیم میشود، میتوانید از یک if تنها استفاده کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>var a int
if b {
a = 100
} else {
a = 10
}
</td><td>
a := 10
if b {
a = 100
}
</td></tr>
</tbody></table>
تعاریف متغیرهای سطح بالا
در ابتدای کد، از واژه کلیدی معمول var
استفاده کنید. در صورتی که نوع متغیر مطابق نوع عبارت مقداردهی باشد، نیازی به مشخص کردن نوع نیست.
var _s string = F()
func F() string { return "A" }
</td><td>
var _s = F()
// از آنجایی که F قبلاً بیان می کند که یک رشته را برمی گرداند، نیازی به تعیین مجدد نوع آن نداریم.
func F() string { return "A" }
</td></tr>
</tbody></table>
اگر نوع عبارت دقیقاً با نوع مورد نیاز مطابقت ندارد، نوع آن را مشخص کنید.
type myError struct{}
func (myError) Error() string { return "error" }
func F() myError { return myError{} }
var _e error = F()
// F یک شی از نوع myError را برمی گرداند اما ما خطا می خواهیم.
از پیشوند "_"
برای متغیرهای خصوصی (Unexported) استفاده کنید
به منظور افزایش دقت و وضوح، متغیرها(var
s) و ثابتهایی(const
s) که عموماً در سطح بالای کد (یعنی در دسترسی پکیج) قرار میگیرند، با استفاده از نشانه "_" (زیرخط) قبل از نام آنها ترکیب شوند. این کار باعث میشود که هنگام استفاده از آنها در دیگر بخشهای کد، به وضوح متوجه شود که این متغیرها و ثابتها به عنوان نمادهای سراسری (global) در نظر گرفته شوند.
دلیل: متغیرها و ثابتهای سطح بالا در محدودهی پکیج قرار دارند و تا حدی کلی هستند. استفاده از نامهای عمومی ممکن است باعث اشتباه در استفاده از مقادیر اشتباه در فایلهای دیگر شود.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// foo.go
const (
defaultPort = 8080
defaultUser = "user"
)
// bar.go
func Bar() {
defaultPort := 9090
...
fmt.Println("Default port", defaultPort)
// اگر خط اول Bar() حذف شود، خطای کامپایل نخواهیم دید.
}
</td><td>
// foo.go
const (
_defaultPort = 8080
_defaultUser = "user"
)
</td></tr>
</tbody></table>
استثنا: در مواردی که مقادیر خطا (error) به صورت unexported باشند، میتوانید از پیشوند err
بدون خط زیر (underscore) استفاده کنید. به منظور اطلاعات بیشتر در مورد نامگذاری خطا، به نامگذاری خطا مراجعه کنید.
جاسازی (Embedding) در ساختارها
اگر نوعهای تو در تو (embedded types) در یک struct وجود دارند، آنها باید در بالای لیست فیلدهای struct قرار گیرند، و باید یک خط خالی بین فیلدهای تو در تو و فیلدهای معمولی وجود داشته باشد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>type Client struct {
version int
http.Client
}
</td><td>
type Client struct {
http.Client
version int
}
</td></tr>
</tbody></table>
درج نوعهای دیگر در یک ساختار (embedding) باید به صورتی باشد که به ویژگیها یا قابلیتها به یک شکل منطقی و معنادار افزوده یا تقویت کند. این کار باید بدون تأثیر منفی قابل مشاهده برای کاربران انجام شود (برای اطلاعات بیشتر، "همچنین: از جاسازی نوعها (Embedding Types) در ساختارهای عمومی خودداری کنید" را ببینید).
استثناء: حتی در نوعهای (unexported) هم، Mutexها (قفلهای همزمانی) نباید به صورت تعبیه شده درج شوند. همچنین میتوانید به مقدار صفر (zero-value) Mutexها معتبر هستند مراجعه کنید.
تعبیه (Embedding) نباید:
- صرفا به منظور زیبایی یا افزایش راحتی باشد.
- ساختن یا استفاده از نوعهای خارجی را پیچیدهتر کند.
- باعث تغییر در مقدار-صفر (zero value) نوع خارجی شود. . اگر نوع خارجی، مقدار صفر مفیدی دارد، پس از تعبیه نوع داخلی، همچنان باید مقدار صفر مفید داشته باشد.
- توابع یا فیلدهای غیرمرتبط از نوع خارجی را به عنوان نتیجه تعبیه نمایش دهد.
- نوعهای (unexported) را نمایش دهد.
- اثرات کپی (copy) انواع خارجی را تغییر دهد.
- API یا معناشناسی انواع خارجی را تغییر دهد.
- یک نمایش غیرمعمول از نوع داخلی را ارائه دهد.
- جزئیات پیادهسازی نوع خارجی را نشان دهد.
- به کاربران اجازه مشاهده یا کنترل اطلاعات داخلی نوع را بدهد.
- با تغییر رفتار کلی عملکردهای داخلی موقعیتهای غیرمنتظره ای را برای کاربران به ارمغان بیاورد.
بطور کلی، تعبیه (Embedding) باید با آگاهی و هدف انجام شود. یک آزمون ساده برای این کار این است: "آیا تمام این متدها/فیلدها باید به صورت مستقیم به نوع خارجی اضافه شوند؟" اگر پاسخ "بله" باشد، معقول است که تعبیه انجام شود؛ اگر پاسخ "بخشی از آنها" یا "خیر" باشد، بهتر است از یک فیلد به جای تعبیه استفاده کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>type A struct {
// بد: حالا دستورهای A.Lock() و A.Unlock() در دسترس هستند،
// اما فایدهای ندارند و به کاربران اجازه میدهند که
// جزئیات داخلی A را کنترل کنند.
sync.Mutex
}
</td><td>
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)
}
</td></tr>
<tr><td>
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
</td><td>
type Book struct {
// خوب: دارای مقدار-صفر مفید است
bytes.Buffer
// other fields
}
// later
var b Book
b.Read(...) // ok
b.String() // ok
b.Write(...) // ok
</td></tr>
<tr><td>
type Client struct {
sync.Mutex
sync.WaitGroup
bytes.Buffer
url.URL
}
</td><td>
type Client struct {
mtx sync.Mutex
wg sync.WaitGroup
buf bytes.Buffer
url url.URL
}
</td></tr>
</tbody></table>
تعاریف متغیرهای محلی
اگر یک متغیر به صراحت به یک مقدار تنظیم میشود، باید از اعلانهای کوتاه متغیر (:=
) استفاده شود.
var s = "foo"
</td><td>
s := "foo"
</td></tr>
</tbody></table>
با این حال، مواردی وجود دارد که در آن مقدار پیشفرض وقتی که از واژه کلیدی var استفاده میشود، واضحتر است. برای مثال، در اعلان برشهای خالی (Empty Slices).
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>func f(list []int) {
filtered := []int{}
for _, v := range list {
if v > 10 {
filtered = append(filtered, v)
}
}
}
</td><td>
func f(list []int) {
var filtered []int
for _, v := range list {
if v > 10 {
filtered = append(filtered, v)
}
}
}
</td></tr>
</tbody></table>
خود nil
یک برش slice
معتبر است
خود nil
به عنوان یک برش با طول صفر (length 0) معتبر شناخته میشود. این بدان معناست که:
-
شما نباید به صورت صریح یک برش با طول صفر را برگردانید. به جای آن باید
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>nil
را برگردانید.
</td><td>if x == "" { return []int{} }
</td></tr> </tbody></table>if x == "" { return nil }
-
برای بررسی اینکه آیا یک برش (slice) خالی است یا نه، همیشه از عبارت
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>len(s) == 0
استفاده کنید.نباید برای بررسی خالی بودن ازnil
استفاده کنید.
</td><td>func isEmpty(s []string) bool { return s == nil }
</td></tr> </tbody></table>func isEmpty(s []string) bool { return len(s) == 0 }
-
مقدار صفر (یک برش که با
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>var
اعلان شده است) بدون نیاز به استفاده از تابعmake()
، بلافاصله قابل استفاده است.
</td><td>nums := []int{} // or, nums := make([]int) if add1 { nums = append(nums, 1) } if add2 { nums = append(nums, 2) }
</td></tr> </tbody></table>var nums []int if add1 { nums = append(nums, 1) } if add2 { nums = append(nums, 2) }
به خاطر داشته باشید که در حالی که یک برش nil معتبر است، اما با یک برش تخصیص داده شده با طول صفر معادل نیست - یکی از آنها nil است و دیگری نیست - و در شرایط مختلف (مانند فرآیند سریالسازی) ممکن است به صورت متفاوتی مدیریت شوند.
کاهش دامنه (scope) متغیرها
در صورت امکان، سعی کنید دامنه متغیرها را محدود کنید. مگر اینکه با قانون تورفتگی (Nesting) را کاهش دهید در تضاد باشد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>err := os.WriteFile(name, data, 0644)
if err != nil {
return err
}
</td><td>
if err := os.WriteFile(name, data, 0644); err != nil {
return err
}
</td></tr>
</tbody></table>
اگر نتیجه یک تابع را بیرون از شرط if نیاز دارید، در اینصورت نباید سعی کنید دامنه متغیر را کاهش دهید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>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
}
</td><td>
data, err := os.ReadFile(name)
if err != nil {
return err
}
if err := cfg.Decode(data); err != nil {
return err
}
fmt.Println(cfg)
return nil
</td></tr>
</tbody></table>
از پارامترهای بی نام (Naked Parameters) خودداری کنید
پارامترهای بینام در فراخوانی توابع میتوانند خوانایی را کاهش دهند. در صورتی که معنای پارامترها واضح نباشد، نامهای پارامترها را با کامنت استایل C (/* ... */
) اضافه کنید.
// func printInfo(name string, isLocal, done bool)
printInfo("foo", true, true)
</td><td>
// func printInfo(name string, isLocal, done bool)
printInfo("foo", true /* isLocal */, true /* done */)
</td></tr>
</tbody></table>
بهتر است پارامترهای 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) پشتیبانی میکند. این نوع رشتهها میتوانند از چندین خط تشکیل شده و شامل نقل قولها باشند. برای افزایش خوانایی کد و جلوگیری از استفاده از رشتههای دستساز با ویژگیهای خاص، از رشتههای متنی خام استفاده کنید. این نوع رشتهها خوانایی کد را خیلی بالا میبرند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>wantError := "unknown name:\"test\""
</td><td>
wantError := `unknown error:"test"`
</td></tr>
</tbody></table>
مقداردهی اولیه ساختارها (structs)
استفاده از نام فیلدها برای مقداردهی اولیه ساختارها
تقریباً همیشه باید نام فیلدها را هنگام مقداردهی اولیه ساختارها (structs) مشخص کنید. این توصیه اکنون توسط ابزار go vet
اجباری شده است.
k := User{"John", "Doe", true}
</td><td>
k := User{
FirstName: "John",
LastName: "Doe",
Admin: true,
}
</td></tr>
</tbody></table>
استثنا: زمانی که تعداد فیلدها سه یا کمتر باشد میتوانید نام فیلدها را در جداول تست حذف کنید.
tests := []struct{
op Operation
want string
}{
{Add, "add"},
{Subtract, "subtract"},
}
حذف فیلدهای مقدارصفر (zero value) در ساختارها
در هنگام مقداردهی اولیه به ساختارها (structs) با استفاده از نام فیلدها، فیلدهایی که مقدار صفر (zero value) دارند را حذف کنید مگر اینکه به دلایل معناداری نیاز به آنها داشته باشید. در غیر این صورت، به Go اجازه دهید این فیلدها را به طور خودکار به مقادیر صفر تنظیم کند.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>user := User{
FirstName: "John",
LastName: "Doe",
MiddleName: "",
Admin: false,
}
</td><td>
user := User{
FirstName: "John",
LastName: "Doe",
}
</td></tr>
</tbody></table>
این به کاهش نویز برای خوانندگان با حذف مقادیر پیشفرض در آن زمینه کمک میکند. فقط مقادیر معنی دار مشخص شده است.
در صورتی که نام فیلدها مفهومی داشته باشند، مقادیر صفر (zero values) را نیز در نظر بگیرید. به عنوان مثال، در جداول تست (Table-driven tests)، استفاده از نام فیلدها حتی زمانی که این مقادیر صفری (zero values) هستند میتواند مفید باشد.
tests := []struct{
give string
want int
}{
{give: "0", want: 0},
// ...
}
استفاده از var
برای ساختارهای مقدارصفر (zero value)
زمانی که تمامی فیلدهای یک ساختار (struct) در یک اعلان حذف شدند، از شکل var
برای اعلان ساختار استفاده کنید.
user := User{}
</td><td>
var user User
</td></tr>
</tbody></table>
این کار باعث تفکیک ساختارهای با مقدار صفر از ساختارهای دارای فیلدهای غیر صفر میشود، مشابه تفکیکی که برای مقداردهی اولیه Mapها ایجاد شده است، و با روش ترجیحی ما برای اعلان برشهای خالی هماهنگ میشود.
مقداردهی اولیه ساختارهای رفرنس دار
از &T{}
به جای new(T)
هنگام مقداردهی اولیه ساختار (struct references) استفاده کنید تا با مقداردهی اولیه ساختار مطابقت داشته باشد.
sval := T{Name: "foo"}
// ناسازگار
sptr := new(T)
sptr.Name = "bar"
</td><td>
sval := T{Name: "foo"}
sptr := &T{Name: "bar"}
</td></tr>
</tbody></table>
مقداردهی اولیه Mapها
برای ایجاد نقشههای خالی و نقشههایی که به صورت برنامهنویسی پر میشوند، استفاده از تابع (..)make
توصیه میشود. این اقدام نه تنها مقداردهی نقشه را از اعلان آن به صورت بصری متمایز میکند، بلکه اگر در آینده اندازه (size hints) در دسترس قرار بگیرد، امکان اضافه کردن آنها را آسان میسازد.
var (
// m1 برای خواندن و نوشتن امن است.
// m2 در نوشتن panic خواهد کرد.
m1 = map[T1]T2{}
m2 map[T1]T2
)
</td><td>
var (
// m1 برای خواندن و نوشتن امن است.
// m2 در نوشتن panic خواهد کرد.
m1 = make(map[T1]T2)
m2 map[T1]T2
)
</td></tr>
<tr><td>
اعلان و مقداردهی اولیه از نظر بصری مشابه هستند.
</td><td>اعلان و مقداردهی اولیه از نظر بصری متمایز هستند
</td></tr> </tbody></table>در صورت امکان، هنگام مقداردهی اولیه نقشه ها با make()
اندازه ظرفیت ارائه دهید. برای اطلاعات بیشتر به تعیین حداکثر ظرفیت ممکن Map مراجعه کنید.
از سوی دیگر، اگر نقشه مجموعهی ثابتی از عناصر را نگه میدارد، از نقشههای لیترال (map literals) برای مقداردهی اولیه استفاده کنید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
</td><td>
m := map[T1]T2{
k1: v1,
k2: v2,
k3: v3,
}
</td></tr>
</tbody></table>
قاعده اساسی در استفاده از نقشهها به شکل زیر است: اگر قرار باشد مجموعهی ثابتی از عناصر را در زمان مقداردهی اولیه اضافه کنید، از نقشههای لیترال (map literals) استفاده کنید. در غیر اینصورت، از تابع make()
استفاده کنید (و در صورت امکان مقدار ظرفیت را مشخص کنید).
قالب بندی رشته ها (strings) خارج از تابع Printf
اگر شما رشتههای قالببندی (format strings) برای توابع استایلدهی، مانند Printf
را خارج از رشته معمولی اعلان میکنید، آنها را به عنوان مقادیر const
ایجاد کنید.
این کمک میکند تا go vet
تجزیه و تحلیل استاتیک رشته قالببندی را انجام دهد.
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
</td><td>
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
</td></tr>
</tbody></table>
نام گذاری توابع به سبک 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 میتواند یک الگوی مفید برای نوشتن تستها باشد تا از تکرار کد در زمانی که منطق آزمون اصلی تکرار میشود جلوگیری شود.
اگر یک سیستم تحت آزمون تست نیاز به آزمایش در برابر شرایط چندگانه دارد که در آن بخشهای خاصی از ورودیها و خروجیها تغییر میکنند، بهترین روش استفاده از تستهای جدولی است. این روش کد را کمتر تکراری میکند و خوانایی آن را بهبود میبخشد.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// 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)
</td><td>
// 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)
})
}
</td></tr>
</tbody></table>
استفاده از جداول تست (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)
})
}
}
</td><td>
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")
}
</td></tr>
</tbody></table>
این پیچیدگی باعث مشکلات در تغییر، درک و اثبات صحت تست میشود.
اگرچه دستورالعملهای دقیقی وجود ندارد، وقتی بین استفاده از تستهای جدولی و تستهای مجزا برای ورودیها/خروجیهای متعدد به یک سیستم تصمیمگیری میکنید، همیشه باید به خوانایی و قابلیت نگهداری فکر کرد.
تست های موازی
تستهای موازی، مانند برخی از حلقههای تخصصی (برای مثال، آنهایی که گوروتینها را ایجاد میکنند یا ارجاعها را به عنوان بخشی از بدنه حلقه میگیرند)، باید دقت کنند که متغیرهای حلقه را به صراحت در محدوده حلقه تخصیص دهند تا اطمینان حاصل شود که مقادیر مورد انتظار را نگه میدارند.
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) که پیشبینی میکنید نیاز به توسعه آنها دارید، استفاده کنید ه خصوص اگر از قبل سه یا بیشتر آرگومان در این توابع داشته باشید.
<table> <thead><tr><th>بد</th><th>خوب</th></tr></thead> <tbody> <tr><td>// package db
func Open(
addr string,
cache bool,
logger *zap.Logger
) (*Connection, error) {
// ...
}
</td><td>
// 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) {
// ...
}
</td></tr>
<tr><td>
پارامترهای کش و لاگر همیشه باید ارائه شوند، حتی اگر کاربر بخواهد از پیش فرض استفاده کند.
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)
</td><td>
گزینهها (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),
)
</td></tr>
</tbody></table>
روش پیشنهادی ما برای پیادهسازی این الگو استفاده از یک رابط (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
که امکان نمایش رشتهای خوانا از گزینهها را فراهم میکند.
همچنین ببینید،
- توابع خود ارجاعی و طراحی گزینهها (Self-referential functions and the design of options)
- Functional options for friendly APIs
بررسی و تمیز کردن (linting)
مهمتر از هر چیز، اعمال یک استاندارد یکسان در کل پروژه است، نه استفاده از یک مجموعه خاص از ابزارهای بررسی کد.
توصیه میکنیم حداقل از لینترهای زیر استفاده کنید، زیرا فکر میکنیم که این ابزارها به شناسایی مشکلات رایج کمک میکنند و همچنین یک استاندارد بالا برای کیفیت کد ایجاد میکنند بدون اینکه غیرضروری تجویز شوند:
- errcheck برای اطمینان از رسیدگی به خطاها
- goimports برای قالب بندی کد و مدیریت واردات
- golint برای اشاره به اشتباهات رایج استایل
- govet برای تجزیه و تحلیل کد برای اشتباهات رایج
- staticcheck برای انجام بررسی های مختلف آنالیز استاتیکی
Lint Runners
ما توصیه میکنیم از golangci-lint به عنوان ابزار اصلی برای اجرای عملیات lint در کد Go استفاده کنید، به دلیل عملکرد برتر آن در پروژههای بزرگ و قابلیت پیکربندی و استفاده از ابزارهای بررسی کد معتبر بسیاری به صورت همزمان. این مخزن (repo) یک فایل پیکربندی .golangci.yml با ابزارهای بررسی کد پیشنهادی و تنظیمات راهنمایی شده را دارد.
golangci-lint دارای لینترهای مختلفی برای استفاده است. لینترهای فوق به عنوان یک مجموعه پایه توصیه میشوند و ما تیمها را تشویق میکنیم که هر گونه لینتر اضافی را که برای پروژههایشان منطقی است اضافه کنند.