۱۰ اشتباه رایج در پروژه های Go — راهنمای کاربردی

۸۵ بازدید
آخرین به‌روزرسانی: ۰۵ مهر ۱۴۰۲
زمان مطالعه: ۱۳ دقیقه
۱۰ اشتباه رایج در پروژه های Go — راهنمای کاربردی

در این مقاله به بررسی فهرستی از 10 اشتباه رایج در پروژه های Go پرداخته‌ایم. توجه داشته باشید که ترتیب بیان اشتباهات لزوماً نشان‌دهنده اهمیت آن‌ها نیست.

مقدار Enum ناشناس

به مثال ساده زیر توجه کنید:

1type Status uint32
2
3const (
4	StatusOpen Status = iota
5	StatusClosed
6	StatusUnknown
7)

در این مثال ما یک enum با استفاده از iota ساختیم که حالت زیر را ایجاد می‌کند:

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

اکنون تصور کنید این نوع Status بخشی از یک درخواست JSON باشد و قرار است marshalled/unmarshalled شود. می‌توان ساختار زیر را طراحی کرد:

1type Request struct {
2	ID        int    `json:"Id"`
3	Timestamp int    `json:"Timestamp"`
4	Status    Status `json:"Status"`
5}

سپس درخواست‌هایی مانند زیر می‌گیریم:

1{
2  "Id": 1234,
3  "Timestamp": 1563362390,
4  "Status": 0
5}

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

1{
2  "Id": 1235,
3  "Timestamp": 1563362390
4}

در این حالت، فیلد Status مربوط به ساختار Request مقدار اولیه‌ای برابر با مقدار صفر می‌گیرند. از این رو به جای StatusUnknown حالت StatusOpen دارد. بهترین رویه این است که مقدار ناشناس را به صورت یک enum با مقدار صفر تعیین کنیم:

1type Status uint32
2
3const (
4	StatusUnknown Status = iota
5	StatusOpen
6	StatusClosed
7)

در این صورت اگر وضعیت، بخشی از درخواست JSON نباشد، مقدار اولیه آن برابر انتظار ما به صورت StatusUnknown خواهد بود.

بنچمارک کردن

بنچمارک کردن صحیح کار دشواری است. عوامل زیادی وجود دارند که می‌توانند بر نتیجه مفروض تأثیر بگذارند. یکی از رایج‌ترین اشتباهات این است که گول برخی بهینه‌سازی‌های کامپایلر را بخوریم. مثال دقیق زیر را از کتابخانه teivah/bitvector در نظر بگیرید:

1func clear(n uint64, i, j uint8) uint64 {
2	return (math.MaxUint64<<j | ((1 << i) - 1)) & n
3}

این تابع بیت‌های درون یک بازه مفروض را پاک می‌کند. برای تهیه بنچمارک از آن می‌توان به صورت زیر عمل کرد:

1func BenchmarkWrong(b *testing.B) {
2	for i := 0; i < b.N; i++ {
3		clear(1221892080809121, 10, 63)
4	}
5}

در این بنچمارک، کامپایلر متوجه خواهد شد که clear یک «تابع برگ» (leaf function) است، یعنی هیچ تابع دیگری را فراخوانی نمی‌کند و از این رو آن را inline خواهد کرد. زمانی که inline شد، متوجه خواهد شد که برخی عوارض جانبی دارد. بنابراین فراخوانی clear به سادگی پاک می‌شود و نتایج نادقیقی تولید می‌کند.

یک گزینه می‌تواند این باشد که نتیجه را به یک متغیر سراسری به صورت زیر تعیین کنیم:

1var result uint64
2
3func BenchmarkCorrect(b *testing.B) {
4	var r uint64
5	for i := 0; i < b.N; i++ {
6		r = clear(1221892080809121, 10, 63)
7	}
8	result = r
9}

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

کاربرد گسترده اشاره‌گرها

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

اگر شما نیز بر این باور هستید، نگاهی به مثال زیر بیندازید:

1package main
2
3import (
4	"encoding/json"
5	"testing"
6)
7
8type foo struct {
9	ID            string  `json:"_id"`
10	Index         int     `json:"index"`
11	GUID          string  `json:"guid"`
12	IsActive      bool    `json:"isActive"`
13	Balance       string  `json:"balance"`
14	Picture       string  `json:"picture"`
15	Age           int     `json:"age"`
16	EyeColor      string  `json:"eyeColor"`
17	Name          string  `json:"name"`
18	Gender        string  `json:"gender"`
19	Company       string  `json:"company"`
20	Email         string  `json:"email"`
21	Phone         string  `json:"phone"`
22	Address       string  `json:"address"`
23	About         string  `json:"about"`
24	Registered    string  `json:"registered"`
25	Latitude      float64 `json:"latitude"`
26	Longitude     float64 `json:"longitude"`
27	Greeting      string  `json:"greeting"`
28	FavoriteFruit string  `json:"favoriteFruit"`
29}
30
31type bar struct {
32	ID            string
33	Index         int
34	GUID          string
35	IsActive      bool
36	Balance       string
37	Picture       string
38	Age           int
39	EyeColor      string
40	Name          string
41	Gender        string
42	Company       string
43	Email         string
44	Phone         string
45	Address       string
46	About         string
47	Registered    string
48	Latitude      float64
49	Longitude     float64
50	Greeting      string
51	FavoriteFruit string
52}
53
54var input foo
55
56func init() {
57	err := json.Unmarshal([]byte(`{
58    "_id": "5d2f4fcf76c35513af00d47e",
59    "index": 1,
60    "guid": "ed687a14-590b-4d81-b0cb-ddaa857874ee",
61    "isActive": true,
62    "balance": "$3,837.19",
63    "picture": "http://placehold.it/32x32",
64    "age": 28,
65    "eyeColor": "green",
66    "name": "Rochelle Espinoza",
67    "gender": "female",
68    "company": "PARLEYNET",
69    "email": "rochelleespinoza@parleynet.com",
70    "phone": "+1 (969) 445-3766",
71    "address": "956 Little Street, Jugtown, District Of Columbia, 6396",
72    "about": "Excepteur exercitation labore ut cupidatat laboris mollit ad qui minim aliquip nostrud anim adipisicing est. Nisi sunt duis occaecat aliquip est irure Lorem irure nulla tempor sit sunt. Eiusmod laboris ex est velit minim ut cillum sunt laborum labore ad sunt.\r\n",
73    "registered": "2016-03-20T12:07:25 -00:00",
74    "latitude": 61.471517,
75    "longitude": 54.01596,
76    "greeting": "Hello, Rochelle Espinoza!You have 9 unread messages.",
77    "favoriteFruit": "banana"
78  }`), &input)
79	if err != nil {
80		panic(err)
81	}
82}
83
84func byPointer(in *foo) *bar {
85	return &bar{
86		ID:            in.ID,
87		Address:       in.Address,
88		Email:         in.Email,
89		Index:         in.Index,
90		Name:          in.Name,
91		About:         in.About,
92		Age:           in.Age,
93		Balance:       in.Balance,
94		Company:       in.Company,
95		EyeColor:      in.EyeColor,
96		FavoriteFruit: in.FavoriteFruit,
97		Gender:        in.Gender,
98		Greeting:      in.Greeting,
99		GUID:          in.GUID,
100		IsActive:      in.IsActive,
101		Latitude:      in.Latitude,
102		Longitude:     in.Longitude,
103		Phone:         in.Phone,
104		Picture:       in.Picture,
105		Registered:    in.Registered,
106	}
107}
108
109func byValue(in foo) bar {
110	return bar{
111		ID:            in.ID,
112		Address:       in.Address,
113		Email:         in.Email,
114		Index:         in.Index,
115		Name:          in.Name,
116		About:         in.About,
117		Age:           in.Age,
118		Balance:       in.Balance,
119		Company:       in.Company,
120		EyeColor:      in.EyeColor,
121		FavoriteFruit: in.FavoriteFruit,
122		Gender:        in.Gender,
123		Greeting:      in.Greeting,
124		GUID:          in.GUID,
125		IsActive:      in.IsActive,
126		Latitude:      in.Latitude,
127		Longitude:     in.Longitude,
128		Phone:         in.Phone,
129		Picture:       in.Picture,
130		Registered:    in.Registered,
131	}
132}
133
134var pointerResult *bar
135
136func BenchmarkByPointer(b *testing.B) {
137	var r *bar
138	b.ResetTimer()
139	for i := 0; i < b.N; i++ {
140		r = byPointer(&input)
141	}
142	pointerResult = r
143}
144
145var valueResult bar
146
147func BenchmarkByValue(b *testing.B) {
148	var r bar
149	b.ResetTimer()
150	for i := 0; i < b.N; i++ {
151		r = byValue(input)
152	}
153	valueResult = r
154}

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

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

توضیح این نتیجه به شیوه مدیریت حافظه در Go ارتباط دارد. توضیح کامل آن خارج از حوصله این مقاله است، اما تلاش می‌کنیم به صورت فشرده به دلیل این مسئله اشاره کنیم.

یک متغیر می‌تواند روی هیپ یا پشته تخصیص یابد. بنابراین به صورت خلاصه:

  • پشته شامل متغیرهای مداوم برای یک goroutine مفروض است. زمانی که تابع بازگشت می‌یابد، متغیرها از پشته برداشته (pop) می‌شوند.
  • هیپ شامل متغیرهای مشترک یعنی متغیرهای سراسری و امثال آن است.

مثال ساده زیر را که یک مقدار بازگشت می‌دهد، در نظر بگیرید:

1func getFooValue() foo {
2	var result foo
3	// Do something
4	return result
5}

در کد فوق، متغیر result از سوی goroutine جاری ایجاد شده است. این متغیر به پشته جاری push می‌شود. زمانی که تابع بازگشت یابد، کلاینت یک کپی از این متغیر دریافت خواهد کرد. متغیر خودش از پشته pop می‌شود. این متغیر همچنان در حافظه وجود دارد تا این که از سوی متغیر دیگری پاک شود، اما دیگر نمی‌توان به آن دسترسی یافت.

اینک همان مثال را با اشاره‌گر در نظر بگیرید:

1func getFooPointer() *foo {
2	var result foo
3	// Do something
4	return &result
5}

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

در این سناریو کامپایلر Go متغیر result را به مکانی منتقل می‌کند که بتواند به اشتراک گذارده شود و اینجا مکانی نیست به جز هیپ.

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

1func main()  {
2	p := &foo{}
3	f(p)
4}

از آنجا که f را درون همان goroutine فراخوانی می‌کنیم، متغیر p نیازی به انتقال ندارد. این متغیر به سادگی به پشته پوش می‌شود و تابع فرعی می‌تواند به آن دسترسی داشته باشد.

برای نمونه این یک نتیجه مستقیم دریافت یک slice در متد Read مربوط به io.Reader به جای بازگشت دادن یکی از آن‌ها است. بازگشت دادن یک io.Reader (که یک اشاره‌گر است) باعث می‌شود به هیپ انتقال پیدا کند.

اینک سؤال این است که چرا پشته چنین سریع است؟ دو دلیل عمده برای آن وجود دارد:

  • نیازی به garbage collector برای پشته وجود ندارد. چنان که گفتیم متغیر یک بار در زمان ایجاد شدن به پشته ارسال می‌شود و سپس یک بار در زمان بازگشت تابع از آن برداشته می‌شود. بنابراین هیچ نیازی به فرایند پیچیده تملک مجدد متغیرهای استفاده نشده و کارهایی از این دست وجود ندارد.
  • هر پشته به یک goroutine تعلق دارد و از این رو لازم نیست متغیر همانند روش ذخیره‌سازی در هیپ همگام‌سازی شود. این وضعیت موجب بهبود عملکرد نیز می‌شود.

در نتیجه زمانی که یک تابع ایجاد می‌کنیم، رفتار پیش‌فرض ما باید استفاده از مقدار به جای اشاره‌گر باشد. یک اشاره‌گر تنها باید در مواردی استفاده شود که می‌خواهیم متغیری را به اشتراک بگذاریم.

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

1go build -gcflags "-m -m"

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

Break کردن یک for/switch یا یک for/select

در مثال زیر وقتی f مقدار true بازگشت دهد چه رخ می‌دهد؟

1for {
2  switch f() {
3  case true:
4    break
5  case false:
6    // Do something
7  }
8}

ما در این حالت گزاره break را فراخوانی می‌کنیم. اما بدین ترتیب گزاره switch به صورت break درخواهد آمد و نه حلقه for. همین مشکل در مورد مثال زیر نیز وجود دارد:

1for {
2  select {
3  case <-ch:
4  // Do something
5  case <-ctx.Done():
6    break
7  }
8}

چنان که می‌بینید Break به گزاره select مربوط است و نه حلقه for. یک راه‌حل ممکن برای break کردن گزاره for/switch یا یک گزاره for/select استفاده از labeled break مانند زیر است:

1for {
2  select {
3  case <-ch:
4  // Do something
5  case <-ctx.Done():
6    break
7  }
8}

مدیریت خطا

Go در مسیر مدیریت خطای خود همچنان تا حدودی ناپخته به نظرمی رسد. دقیقاً به همین دلیل است که یکی از بزرگ‌ترین انتظارات ما از نسخه 2 زبان Go حل این مشکل است.

کتابخانه استاندارد کنونی (پیش از Go 1.13) تنها تابع‌هایی برای ساخت خطاها ارائه می‌کرد، بنابراین شاید بهتر باشد نگاهی به pkg/errors (+) داشته باشید.

این کتابخانه روش مناسبی برای رعایت این قاعده ساده است که البته در اغلب موارد نادیده گرفته می‌شود:

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

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

در ادامه مثالی در همین رابطه مشاهده می‌کنید. در این مثال یک فراخوانی REST منجر به ایجاد مشکل در پایگاه داده می‌شود:

unable to server HTTP POST request for customer 1234
 |_ unable to insert customer contract abcd
     |_ unable to commit transaction

اگر از کتابخانه pkg/errors استفاده کنیم، می‌توانیم به صورت زیر عمل کنیم:

1func postHandler(customer Customer) Status {
2	err := insert(customer.Contract)
3	if err != nil {
4		log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
5		return Status{ok: false}
6	}
7	return Status{ok: true}
8}
9
10func insert(contract Contract) error {
11	err := dbQuery(contract)
12	if err != nil {
13		return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
14	}
15	return nil
16}
17
18func dbQuery(contract Contract) error {
19	// Do something then fail
20	return errors.New("unable to commit transaction")
21}

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

همچنین می‌توانیم منشأ خطا را نیز بررسی کنیم تا بدانیم آیا به یک تلاش مجدد نیاز داریم یا نه. فرض کنید یک پکیج db از یک کتابخانه بیرونی با دسترسی‌های پایگاه داده سر و کار دارد. این کتابخانه می‌تواند یک خطای گذرا (موقت) به نام db.DBError ایجاد کند. برای تعیین این که آیا به تلاش مجدد نیاز داریم یا نه، باید منشأ خطا را بررسی کنیم:

1func postHandler(customer Customer) Status {
2	err := insert(customer.Contract)
3	if err != nil {
4		switch errors.Cause(err).(type) {
5		default:
6			log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
7			return Status{ok: false}
8		case *db.DBError:
9			return retry(customer)
10		}
11
12	}
13	return Status{ok: true}
14}
15
16func insert(contract Contract) error {
17	err := db.dbQuery(contract)
18	if err != nil {
19		return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)
20	}
21	return nil
22}

این کار با استفاده از errors.Cause انجام می‌یابد که آن نیز از پکیج pkg/errors می‌آید:

یک اشتباه رایج، استفاده ناقص از پکیج pkg/errors است. برای نمونه بررسی خطا به صورت زیر اجرا می‌شود:

1switch err.(type) {
2default:
3  log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID)
4  return Status{ok: false}
5case *db.DBError:
6  return retry(customer)
7}

در این مثال اگر db.DBError پوشش پیدا کند، هرگز موجب تلاش مجدد نمی‌شود.

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

برخی اوقات ما می‌دانیم که طول نهایی یک Slice چه قدر خواهد بود. برای نمونه تصور کنید باید یک Slice به نام Foo را به یک Slice به نام Bar تبدیل کنیم، یعنی دو Slice طولی یکسان دارند. ما غالباً می‌بینیم که Slice-ها به صورت زیر مقداردهی اولیه می‌شوند:

1var bars []Bar
2bars := make([]Bar, 0)

توجه کنید که Slice ساختاری جادویی محسوب نمی‌شود. این ساختار در صورت نبود فضای بیشتر، در پس‌زمینه یک راهبرد رشد را پیاده‌سازی می‌کند. در این حالت یک آرایه جدید به صورت خودکار ایجاد می‌شود که ظرفیت بالاتری دارد و همه آیتم‌ها روی آن کپی می‌شوند.

اینک تصور کنید نیاز داریم این عملیات رشد را چند بار تکرار کنیم، زیرا []Foo ما شامل هزاران آیتم است. پیچیدگی زمانی عملیات درج همچنان (O(1 خواهد بود، اما در عمل با مشکل عملکردی مواجه می‌شویم.

از این رو اگر طول نهایی را بدانیم می‌توانیم به یکی از روش‌های زیر عمل کنیم:

مقداردهی با طول از قبل تعریف‌شده:

1func convert(foos []Foo) []Bar {
2	bars := make([]Bar, len(foos))
3	for i, foo := range foos {
4		bars[i] = fooToBar(foo)
5	}
6	return bars
7}

یا مقداردهی با طول 0 و ظرفیت از پیش تعریف‌شده:

1func convert(foos []Foo) []Bar {
2	bars := make([]Bar, 0, len(foos))
3	for _, foo := range foos {
4		bars = append(bars, fooToBar(foo))
5	}
6	return bars
7}

بهترین گزینه کدام است؟ گزینه اول کمی سریع‌تر است. با این حال ممکن است گزینه دوم را بیشتر ترجیح بدهید زیرا موجب می‌شود همه چیز منسجم‌تر بماند. صرف‌نظر از این که اندازه اولیه را می‌دانیم یا نه، افزودن یک عنصر به انتهای یک slice با استفاده از append انجام می‌یابد.

مدیریت چارچوب (Context)

context.Contex غالباً از سوی توسعه‌دهندگان به اشتباه فهمیده می‌شود. بر اساس مستندات رسمی:

یک چارچوب حامل یک ضرب‌الاجل، یک سیگنال لغو و دیگر مقادیر روی API است.

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

در ادامه آن را بیشتر باز می‌کنیم. یک چارچوب می‌تواند شامل موارد زیر باشد:

  • یک ضرب‌الاجل یا deadline: این ضرب‌الاجل یا شامل یک مدت زمان (برای مثال 250 میلی‌ثانیه) یا یک تاریخ-زمان (مثلاً 2019-01-08 01:00:00) است که در زمان فرا رسیدن این زمان، باید فعالیت جاری یعنی درخواست I/O، انتظار برای ورودی کانال و غیره را لغو کنیم.
  • سیگنال لغو (Cancelation Signal) نیز رفتار مشابهی دارد. زمانی که یک سیگنال دریافت شود باید فعالیت جاری متوقف شود. برای نمونه تصور کنید که ما مشغول دریافت دو درخواست هستیم. فرض کنید یکی برای درج برخی داده‌ها و دیگری برای لغو درخواست اول (چون دیگر موضوعیت ندارد یا هر دلیل دیگر) است. این وضعیت از طریق استفاده از چارچوب قالب لغو در فراخوانی اول میسر است تا به محض دریافت درخواست دوم بتوانیم اولی را لغو کنیم.
  • یک لیست از کلید/مقدار که هر دو از نوع {}interface هستند.

دو نکته وجود دارد که باید اضافه کنیم. ابتدا این که یک چارچوب composable است. بنابراین می‌توانیم یک چارچوب داشته باشیم که شامل یک ضرب‌الاجل باشد و یک لیست از کلید/مقدار نیز برای نمونه داشته باشد. به علاوه goroutine-های چندگانه می‌توانند چارچوب واحدی را به اشتراک بگذارند، بدین ترتیب سیگنال لغو می‌تواند به صورت بالقوه چند فعالیت را متوقف کند.

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

یک اپلیکیشن Go بر مبنای urfave/cli ساخته شده است. این کتابخانه‌ای زیبا است که برای ایجاد اپلیکیشن‌های خط فرمان در Go استفاده می‌شود. زمانی که کار توسعه اپلیکیشن آغاز می‌شود، توسعه‌دهنده از نوعی چارچوب اپلیکیشن ارث‌بری می‌کند. این بدان معنی است که وقتی اپلیکیشن متوقف شود، کتابخانه از این چارچوب برای ارسال سیگنال لغو استفاده می‌کند.

در چنین حالتی دقیقاً همین چارچوب مستقیماً در زمان فراخوانی نقطه انتهایی gRPC ارسال می‌شود. ولی این آن چیزی نیست که ما می‌خواهیم. به جای آن ما می‌خواهیم به کتابخانه gRPC اعلام کنیم که درخواست را زمانی که اپلیکیشن متوقف شده است و یا پس از مثلاً 100 میلی‌ثانیه لغو کند.

برای نیل به این مقصود می‌توانیم یک «چارچوب ترکیبی» (composed context) ایجاد کنیم. اگر parent نام چارچوب اپلیکیشن باشد، در این حالت به صورت زیر عمل می‌کنیم:

1ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)
2response, err := grpcClient.Send(ctx, request)

درک چارچوب‌ها کار چندان پیچیده‌ای نیست و یکی از بهترین ویژگی‌های زبان برنامه‌نویسی Go محسوب می‌شوند.

عدم استفاده از گزینه race-

خطایی که غالباً در زمان تست کردن اپلیکیشن‌های Go مشاهده می‌شود، عدم استفاده از گزینه race- است. با این که Go طوری طراحی شده است که برنامه‌نویسی concurrent راحت‌تر و مستعد خطای کمتری است، اما همچنان از مشکلات همزمانی زیادی رنج می‌برد.

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

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

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

1func count(filename string) (int, error) {
2	file, err := os.Open(filename)
3	if err != nil {
4		return 0, errors.Wrapf(err, "unable to open %s", filename)
5	}
6	defer file.Close()
7
8	scanner := bufio.NewScanner(file)
9	count := 0
10	for scanner.Scan() {
11		if scanner.Text() == "" {
12			count++
13		}
14	}
15	return count, nil
16}

Filename به عنوان یک ورودی عرضه می‌شود و از این رو آن را باز می‌کنیم و سپس منطق خود را پیاده‌سازی می‌کنیم.

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

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

Go دو لایه تجرید عالی به این منظور دارد که یکی io.Reader و دیگری io.Writer است. به جای ارسال یک نام فایل می‌توانیم به سادگی یک io.Reader ارسال کنیم و منبع داده را تجرید نماییم.

شاید بپرسید io.Reader یک فایل است؟ یک بدنه HTTP یک بافر بایت و یا شاید چیز دیگری است؟ این مسئله اهمیتی ندارد زیرا ما همچنان همان متد Read را داریم.

در این مورد می‌توانیم حتی ورودی را بافر کنیم تا آن را خط به خط بخوانیم. بنابراین می‌توانیم از bufio.Reader و از متد ReadLine آن استفاده کنیم:

1func count(reader *bufio.Reader) (int, error) {
2	count := 0
3	for {
4		line, _, err := reader.ReadLine()
5		if err != nil {
6			switch err {
7			default:
8				return 0, errors.Wrapf(err, "unable to read")
9			case io.EOF:
10				return count, nil
11			}
12		}
13		if len(line) == 0 {
14			count++
15		}
16	}
17}

مسئولیت باز کردن خود فایل اکنون به کلاینت count واگذار شده است:

1file, err := os.Open(filename)
2if err != nil {
3  return errors.Wrapf(err, "unable to open %s", filename)
4}
5defer file.Close()
6count, err := count(bufio.NewReader(file))

در پیاده‌سازی دوم تابع می‌تواند صرف‌نظر از این که منبع داده واقعی چیست فراخوانی شود. در عین حال، تست‌های واحد تسهیل می‌شوند، زیرا می‌توان bufio.Reader از یک string ساخت:

1count, err:= count(bufio.NewReader(strings.NewReader("input")))

Goroutine-ها و متغیرهای حلقه

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

1ints := []int{1, 2, 3}
2for _, i := range ints {
3  go func() {
4    fmt.Printf("%v\n", i)
5  }()
6}

اگر فکر می‌کنید خروجی مثال فوق 1 2 3 با هر نوع ترتیبی است اشتباه می‌کنید. در این مثال هر goroutine وهله یکسانی از متغیر را به اشتراک می‌گذارد و از این رو خروجی آن 3 3 3 است.

دو راه‌حل برای این مشکل وجود دارد. راه‌حل نخست این است که مقدار متغیر i را به بستار (تابع درونی) ارسال کنیم:

1ints := []int{1, 2, 3}
2for _, i := range ints {
3  go func(i int) {
4    fmt.Printf("%v\n", i)
5  }(i)
6}

و راه‌حل دوم این است که متغیر دیگری درون دامنه حلقه for ایجاد کنیم:

1ints := []int{1, 2, 3}
2for _, i := range ints {
3  i := i
4  go func() {
5    fmt.Printf("%v\n", i)
6  }()
7}

با این که فراخوانی i:= i ممکن است کمی عجیب به نظر برسد، اما کاملاً معتبر است. درون یک حلقه بودن یعنی درون یک دامنه دیگر قرار داریم. بنابراین i:= i یک وهله متغیر دیگری به نام i می‌سازد. البته می‌توانیم آن را به نام دیگری نام‌گذاری کنیم تا خوانایی کد افزایش یابد.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
itnext
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *