برنامه نویسیبک اند

آموزش ساخت یک سرور HTTP در Go

مقدمه

بسیاری از توسعه‌دهندگان بخشی از وقت خود را صرف ساخت سرورها برای توزیع محتوا روی اینترنت می‌کنند. پروتکل انتقال ابرمتن (HTTP) بخش زیادی از این محتوا را سرو می‌کند؛ چه درخواستی برای یک عکس گربه باشد و چه درخواستی برای بارگذاری همان آموزشی که همین حالا در حال خواندنش هستید. کتابخانه استاندارد Go به‌صورت داخلی از ساخت سرور HTTP برای سرو کردن محتوای وب شما یا ارسال درخواست‌های HTTP به آن سرورها پشتیبانی می‌کند.

در این آموزش، با استفاده از کتابخانه استاندارد Go یک سرور HTTP می‌سازید و سپس سرور خود را گسترش می‌دهید تا داده‌ها را از رشته کوئری (Query String)، بدنه و داده‌های فرم درخواست بخواند. همچنین برنامه خود را به‌روزرسانی می‌کنید تا به درخواست‌ها با هدرها و کدهای وضعیت سفارشی HTTP پاسخ دهد. علاوه بر این، یاد می‌گیرید که چطور میدل‌ور (Middleware) را برای پردازش قابل‌استفاده مجدد درخواست‌ها پیاده‌سازی کنید، Endpointهای JSON API بسازید و شیوه‌های آماده production — شامل Timeout، خاموش‌شدن منظم (Graceful Shutdown) و پیکربندی HTTPS — را اعمال کنید.

نکات کلیدی

  • پکیج net/http در Go بلوک‌های سازنده اصلی را برای ساخت سرورهای HTTP آماده production بدون وابستگی خارجی فراهم می‌کند؛ شامل مسیریابی، پشتیبانی میدل‌ور و قابلیت‌های TLS.
  • اینترفیس http.Handler پایه اکوسیستم HTTP در Go است — برای هندلرهای دارای وضعیت (Stateful) متد ServeHTTP را پیاده‌سازی کنید یا از http.HandlerFunc برای تبدیل توابع ساده به هندلر استفاده کنید.
  • همیشه به‌جای استفاده از مالتی‌پلکسر سراسری پیش‌فرض، http.ServeMux خودتان را بسازید تا از مشکلات سختی که ناشی از تغییر وضعیت مشترک توسط چند بخش از کدتان است، جلوگیری کنید.
  • در محیط production، Timeoutهای صریح سرور (‏ReadTimeout، WriteTimeout و IdleTimeout) را پیکربندی کنید تا از باز نگه‌داشتن دائمی اتصال‌ها توسط کلاینت‌های کند یا مخرب و تخلیه منابع سرور جلوگیری شود.
  • توابع میدل‌وری که http.Handler می‌گیرند و برمی‌گردانند، به شما اجازه می‌دهند هندلرها را با منطق قابل‌استفاده مجدد برای لاگ‌گیری، احراز هویت، هدرها و سایر مسائل مشترک (Cross-Cutting) بپوشانید؛ بدون تغییر دادن هر هندلر.
  • با استفاده از ()http.Server.Shutdown خاموش‌شدن منظم را پیاده‌سازی کنید تا پذیرش اتصال‌های جدید متوقف شود، درحالی‌که درخواست‌های در حال پردازش کامل می‌شوند و از قطع‌شدن اتصال‌ها هنگام ری‌استارت یا استقرار سرور جلوگیری می‌شود.
  • Go سه راه برای دسترسی به ورودی کاربر فراهم می‌کند: رشته کوئری از طریق ()r.URL.Query، بدنه درخواست از طریق r.Body و داده‌های فرم از طریق ()r.PostFormValue یا ()r.FormValue — گزینه مناسب را بر اساس مورد استفاده خود انتخاب کنید.
  • همیشه هدرهای مناسب HTTP را با ()w.Header().Set قبل از فراخوانی ()w.WriteHeader تنظیم کنید؛ چرا که پس از ارسال کد وضعیت و بدنه به کلاینت، هدرها دیگر قابل تغییر نیستند.
  • سرورهای production نیازمند HTTPS با پیکربندی TLS، هدرهای امنیتی (X-Frame-Options و Content-Security-Policy)، Endpointهای بررسی سلامت (Health Check)، لاگ‌گیری ساختاریافته و یکپارچگی مانیتورینگ هستند.
  • مدل هم‌زمانی (Concurrency) مبتنی بر گوروتین در Go هر درخواست HTTP را در نخ (Thread) سبکِ خودش پردازش می‌کند و این موضوع Go را به‌طور استثنایی برای ساخت سرورهای با کارایی بالا که هزاران اتصال هم‌زمان را به‌طور کارآمد مدیریت می‌کنند، مناسب می‌سازد.

پیش‌نیازها

برای دنبال کردن این آموزش به موارد زیر نیاز دارید:

  • نسخه ۱.۱۶ یا بالاتر Go نصب‌شده. برای راه‌اندازی، آموزش «نصب Go» مربوط به سیستم‌عامل خود را دنبال کنید.
  • توانایی استفاده از curl برای ارسال درخواست‌های وب. برای آشنایی بیشتر با curl، مقاله «دانلود فایل‌ها با cURL» را ببینید.
  • آشنایی با استفاده از JSON در Go که در آموزش «استفاده از JSON در Go» آمده است.
  • تجربه کار با پکیج context در Go که می‌توانید از آموزش «استفاده از Context در Go» به دست آورید.
  • تجربه اجرای گوروتین‌ها و خواندن کانال‌ها که از آموزش «اجرای هم‌زمان چند تابع در Go» قابل کسب است.
  • آشنایی با نحوه ساخت و ارسال درخواست‌های HTTP (توصیه‌شده).

راه‌اندازی پروژه

در Go، بیشتر قابلیت‌های HTTP توسط پکیج net/http در کتابخانه استاندارد فراهم می‌شوند و بقیه ارتباط شبکه‌ای توسط پکیج net ارائه می‌شود. پکیج net/http نه‌تنها توانایی ارسال درخواست‌های HTTP را در بر دارد، بلکه یک سرور HTTP هم فراهم می‌کند که می‌توانید از آن برای مدیریت آن درخواست‌ها استفاده کنید.

در این بخش، برنامه‌ای می‌سازید که از تابع http.ListenAndServe برای راه‌اندازی یک سرور HTTP استفاده می‌کند که به مسیرهای درخواست / و /hello پاسخ می‌دهد. سپس آن برنامه را گسترش می‌دهید تا چندین سرور HTTP را در یک برنامه اجرا کند.

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

ابتدا، دایرکتوری projects را بسازید و وارد آن شوید:

mkdir projects
cd projects

سپس، دایرکتوری پروژه خود را بسازید و وارد آن شوید. در این مورد، از دایرکتوری httpserver استفاده کنید:

mkdir httpserver
cd httpserver

حالا که دایرکتوری برنامه خود را ساخته‌اید و در دایرکتوری httpserver هستید، می‌توانید پیاده‌سازی سرور HTTP خود را شروع کنید.

گوش دادن به درخواست‌ها و ارائه پاسخ‌ها

یک سرور HTTP در Go شامل دو جزء اصلی است: سرور که به درخواست‌های رسیده از کلاینت‌های HTTP گوش می‌دهد و یک یا چند هندلر درخواست که به آن درخواست‌ها پاسخ می‌دهند. در این بخش، ابتدا از تابع http.HandleFunc استفاده می‌کنید تا به سرور بگویید هنگام دریافت درخواستی، کدام تابع را برای مدیریت آن فراخوانی کند. سپس از تابع http.ListenAndServe استفاده می‌کنید تا سرور را راه‌اندازی کنید و به آن بگویید به دنبال درخواست‌های HTTP جدید بگردد و سپس آن‌ها را با توابع هندلری که تنظیم کرده‌اید سرو کند.

حالا، داخل دایرکتوری httpserver که ساختید، با nano یا ویرایشگر دلخواه خود، فایل main.go را باز کنید:

nano main.go

در فایل main.go، دو تابع به نام‌های getRoot و getHello می‌سازید تا به‌عنوان توابع هندلر شما عمل کنند. سپس، یک تابع main می‌سازید و از آن برای راه‌اندازی هندلرهای درخواست با تابع http.HandleFunc استفاده می‌کنید؛ با پاس دادن مسیر / برای تابع هندلر getRoot و مسیر /hello برای تابع هندلر getHello. وقتی هندلرها را راه‌اندازی کردید، تابع http.ListenAndServe را فراخوانی کنید تا سرور راه‌اندازی شود و به درخواست‌ها گوش دهد.

کد زیر را به فایل اضافه کنید تا برنامه‌تان راه‌اندازی و هندلرها تنظیم شوند:

main.go

package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
)

func getRoot(w http.ResponseWriter, r *http.Request) {
    fmt.Printf("got / request\n")
    io.WriteString(w, "This is my website!\n")
}
func getHello(w http.ResponseWriter, r *http.Request) {
    fmt.Printf("got /hello request\n")
    io.WriteString(w, "Hello, HTTP!\n")
}

در این اولین بخش کد، پکیج برنامه Go خود را تنظیم می‌کنید، پکیج‌های موردنیاز را ایمپورت می‌کنید و دو تابع می‌سازید: تابع getRoot و تابع getHello. هر دوی این توابع همان امضای (Signature) تابع را دارند؛ یعنی آرگومان‌های یکسانی قبول می‌کنند: یک مقدار http.ResponseWriter و یک مقدار *http.Request. این امضای تابع برای توابع هندلر HTTP استفاده می‌شود و به‌صورت http.HandlerFunc تعریف شده است. وقتی درخواستی به سرور ارسال می‌شود، سرور این دو مقدار را با اطلاعات مربوط به درخواست تنظیم کرده و سپس تابع هندلر را با آن مقادیر فراخوانی می‌کند.

در یک http.HandlerFunc، مقدار http.ResponseWriter (که در هندلرهای شما w نام دارد) برای کنترل اطلاعات پاسخی که به کلاینتِ درخواست‌کننده برمی‌گردد استفاده می‌شود؛ مانند بدنه پاسخ یا کد وضعیت. سپس، مقدار *http.Request (که در هندلرهای شما r نام دارد) برای دریافت اطلاعات درباره درخواستی که به سرور آمده استفاده می‌شود؛ مانند بدنه‌ای که در یک درخواست POST ارسال می‌شود یا اطلاعاتی درباره کلاینتی که درخواست را فرستاده است.

فعلاً در هر دو هندلر HTTP خود، از fmt.Printf برای چاپ زمان ورود درخواست به تابع هندلر استفاده می‌کنید و سپس با http.ResponseWriter متنی را به بدنه پاسخ می‌فرستید. http.ResponseWriter یک io.Writer است؛ یعنی می‌توانید از هر چیزی که قادر به نوشتن در آن اینترفیس باشد، برای نوشتن در بدنه پاسخ استفاده کنید. در این مورد، از تابع io.WriteString برای نوشتن پاسخ خود در بدنه استفاده می‌کنید.

درحالی‌که http.HandlerFunc راهی convenient برای استفاده از یک تابع ساده به‌عنوان هندلر درخواست است، ارزش دارد که اینترفیس http.Handler را که پایه ساخت آن است هم بشناسید. اینترفیس http.Handler به‌صورت هر نوعی (Type) تعریف شده که یک متد واحد را پیاده‌سازی می‌کند:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

هر ساختاری (Struct) که متد ServeHTTP با این امضا داشته باشد، این اینترفیس را ارضا می‌کند و می‌توان از آن در هر جایی که پکیج HTTP در Go انتظار هندلر دارد استفاده کرد؛ از جمله به‌عنوان آرگومان دوم http.ListenAndServe. این موضوع زمانی مفید می‌شود که هندلر شما نیاز به حمل وضعیت (State) — مانند اتصال پایگاه داده یا یک مقدار پیکربندی — داشته باشد و نخواهید آن را از طریق یک متغیر سراسری پاس بدهید:

type helloHandler struct {
    db *sql.DB
}

func (h *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello from a handler with a DB connection!")
}

در مقابل، http.HandlerFunc یک میان‌بر است. خودش نوعی است که http.Handler را پیاده‌سازی می‌کند، اما به شما اجازه می‌دهد هر تابعی با امضای درست را بدون تعریف Struct جدید، مثل یک هندلر رفتار کنید. در زیر کاپوت، متد ServeHTTP آن صرفاً خودِ تابع را فراخوانی می‌کند. پس وقتی می‌نویسید:

mux.HandleFunc("/", getRoot)

Go تابع getRoot شما را به یک مقدار http.HandlerFunc تبدیل کرده و آن را به‌عنوان یک http.Handler روی mux ثبت می‌کند. هر دو رویکرد نتیجه یکسانی تولید می‌کنند؛ تمایز آنها، تفاوتِ راحتی در برابر ساختار است. برای هندلرهای ساده، http.HandlerFunc کد را مختصر نگه می‌دارد. برای هندلرهایی که نیاز به اشتراک وضعیت یا تست شدن به‌صورت ایزوله دارند، پیاده‌سازی مستقیم http.Handler روی یک Struct انتخاب تمیزتری است.

حالا، ساخت برنامه خود را با شروع تابع main ادامه دهید:

main.go

...

func main() {
    http.HandleFunc("/", getRoot)
    http.HandleFunc("/hello", getHello)
    
    err := http.ListenAndServe(":3333", nil)

...

در تابع main، دو فراخوانی به تابع http.HandleFunc دارید. هر فراخوانی از این تابع، یک تابع هندلر را برای یک مسیر درخواست مشخص در مالتی‌پلکسر سرور پیش‌فرض تنظیم می‌کند. مالتی‌پلکسر سرور یک http.Handler است که می‌تواند به مسیر درخواست نگاه کرده و تابع هندلرِ مرتبط با آن مسیر را فراخوانی کند. پس در برنامه خود، به مالتی‌پلکسر سرور پیش‌فرض می‌گویید وقتی کسی مسیر / را درخواست کرد، تابع getRoot و وقتی کسی مسیر /hello را درخواست کرد، تابع getHello را فراخوانی کند.

وقتی هندلرها تنظیم شدند، تابع http.ListenAndServe را فراخوانی می‌کنید که به سرور HTTP سراسری می‌گوید روی یک پورت مشخص و با یک http.Handler اختیاری، به درخواست‌های ورودی گوش دهد. در برنامه خود، به سرور می‌گویید روی «:3333» گوش دهد. با مشخص‌نکردن آدرس IP قبل از دونقطه، سرور روی تک‌تک آدرس‌های IP مرتبط با کامپیوتر شما گوش می‌دهد و روی پورت 3333 است. یک پورت شبکه — مانند 3333 در اینجا — راهی است که یک کامپیوتر بتواند هم‌زمان برنامه‌های زیادی با هم ارتباط برقرار کنند. هر برنامه از پورت خودش استفاده می‌کند؛ پس وقتی کلاینتی به یک پورت مشخص متصل می‌شود، کامپیوتر می‌داند که برنامه مربوط به آن پورت باید به کدام برنامه پاس داده شود. اگر می‌خواستید فقط اتصالات به localhost (نام میزبان برای آدرس IP 127.0.0.1) مجاز باشند، به‌جای آن می‌توانستید بنویسید 127.0.0.1:3333.

تابع http.ListenAndServe شما همچنین برای پارامتر http.Handler مقدار nil پاس می‌دهد. این به تابع ListenAndServe می‌گوید که می‌خواهید از مالتی‌پلکسر سرور پیش‌فرض استفاده کنید، نه هندلری که خودتان ساخته‌اید.

فراخوانی ListenAndServe یک فراخوانی مسدودکننده (Blocking) است؛ یعنی برنامه شما اجرا خود را ادامه نمی‌دهد تا وقتی که ListenAndServe به پایان برسد. با این حال، ListenAndServe تا وقتی برنامه‌تان در حال اجراست یا به سرور HTTP گفته شود خاموش شود، تمام نمی‌شود. با وجود مسدودکننده بودن ListenAndServe و نداشتن راهی برای خاموش‌کردن سرور در برنامه شما، گنجاندن مدیریت خطا همچنان مهم است؛ چون چند حالت وجود دارد که فراخوانی ListenAndServe می‌تواند شکست بخورد. پس، مدیریت خطا را به فراخوانی ListenAndServe در تابع main خود اضافه کنید:

main.go

...

func main() {
    ...
    err := http.ListenAndServe(":3333", nil)

    if errors.Is(err, http.ErrServerClosed) {
        fmt.Printf("server closed\n")
    } else if err != nil {
        fmt.Printf("error starting server: %s\n", err)
        os.Exit(1)
    }
}

اولین خطایی که بررسی می‌کنید، http.ErrServerClosed است که وقتی به سرور گفته می‌شود خاموش یا بسته شود، برگردانده می‌شود. این معمولاً یک خطای موردانتظار است؛ چون خودتان سرور را خاموش می‌کنید، اما می‌تواند برای نشان‌دادن دلیل توقف سرور در خروجی هم استفاده شود. در دومین بررسی خطا، هر خطای دیگری را بررسی می‌کنید. اگر این اتفاق بیفتد، خطا روی صفحه چاپ شده و سپس برنامه با کد خطای ۱ با استفاده از تابع os.Exit خاتمه می‌یابد.

یک خطایی که ممکن است هنگام اجرای برنامه خود ببینید، خطای address already in use است. این خطا وقتی برگردانده می‌شود که ListenAndServe نتواند روی آدرس یا پورتی که ارائه کرده‌اید گوش دهد؛ چون برنامه دیگری از قبل در حال استفاده از آن است. گاهی این اتفاق می‌افتد اگر پورت پرکاربرد باشد و برنامه دیگری روی کامپیوتر شما از آن استفاده کند، اما اگر چند نسخه از برنامه خودتان را چند بار اجرا کنید هم می‌تواند رخ دهد. اگر در حین کار روی این آموزش این خطا را دیدید، مطمئن شوید برنامه‌تان را از مرحله قبلی متوقف کرده‌اید قبل از اجرای مجدد آن.

توجه: اگر خطای address already in use را دیدید و نسخه دیگری از برنامه‌تان در حال اجرا نیست، ممکن است یعنی برنامه دیگری از آن استفاده می‌کند. در این صورت، هر جا در این آموزش عدد 3333 را دیدید، آن را به عددی بالاتر از 1024 و پایین‌تر از 65535 — مانند 3334 — تغییر دهید و دوباره امتحان کنید. اگر همچنان خطا را دیدید، ممکن است لازم باشد برای پیدا کردن پورتِ استفاده‌نشده چند بار تلاش کنید. وقتی پورتِ کارآمدی پیدا کردید، از همان برای همه دستورات این آموزش استفاده کنید.

حالا که کد شما آماده است، فایل main.go را ذخیره کرده و برنامه خود را با go run اجرا کنید. برخلاف برنامه‌های Go دیگری که ممکن است نوشته باشید، این برنامه به‌تنهایی و فوراً خارج نمی‌شود. وقتی برنامه را اجرا کردید، به دستورات بعدی بروید:

go run main.go

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

در این ترمینال دوم، از برنامه curl برای ارسال یک درخواست HTTP به سرور HTTP خود استفاده کنید. curl ابزاری است که معمولاً به‌صورت پیش‌فرض روی بسیاری از سیستم‌ها نصب است و می‌تواند به انواع مختلفی از سرورها درخواست بفرستد. در این آموزش از آن برای ارسال درخواست‌های HTTP استفاده می‌کنید. سرور شما روی پورت 3333 کامپیوترتان در انتظار اتصال است؛ پس باید درخواست خود را به localhost روی همان پورت بفرستید:

curl http://localhost:3333

خروجی به این شکل خواهد بود:

خروجی:

This is my website!

در خروجی، پاسخ This is my website! را از تابع getRoot می‌بینید؛ چون به مسیر / روی سرور HTTP خود دسترسی پیدا کردید.

حالا در همان ترمینال، درخواستی به همان میزبان و پورت بفرستید، اما این بار مسیر /hello را به انتهای دستور curl خود اضافه کنید:

curl http://localhost:3333/hello

خروجی به این شکل خواهد بود:

خروجی:

Hello, HTTP!

این بار پاسخ Hello, HTTP! را از تابع getHello می‌بینید.

اگر به ترمینالی که تابع سرور HTTP شما در آن در حال اجراست برگردید، دو خط خروجی از سرور خود دارید: یکی برای درخواست / و دیگری برای درخواست /hello:

خروجی:

got / request
got /hello request

چون سرور تا پایان اجرای برنامه به کار خود ادامه می‌دهد، باید خودتان آن را متوقف کنید. برای این کار، CONTROL+C را فشار دهید تا سیگنال وقفه (Interrupt) برای توقف به برنامه‌تان ارسال شود.

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

مالتی‌پلکس‌کردن هندلرهای درخواست

وقتی در بخش قبل سرور HTTP خود را راه‌اندازی کردید، به تابع ListenAndServe برای پارامتر http.Handler مقدار nil پاس دادید؛ چون از مالتی‌پلکسر سرور پیش‌فرض استفاده می‌کردید. از آنجا که http.Handler یک اینترفیس است، می‌توان Struct خودتان را ساخت که این اینترفیس را پیاده‌سازی کند. اما گاهی فقط به یک http.Handler پایه نیاز دارید که برای یک مسیر درخواست مشخص، یک تابع واحد را فراخوانی کند؛ مانند مالتی‌پلکسر سرور پیش‌فرض. در این بخش، برنامه خود را به‌روزرسانی می‌کنید تا از http.ServeMux استفاده کند؛ یک مالتی‌پلکسر سرور و پیاده‌سازی http.Handler که توسط پکیج net/http فراهم شده و می‌توانید از آن برای این موارد استفاده کنید.

ساختار http.ServeMux به همان روش مالتی‌پلکسر سرور پیش‌فرض قابل پیکربندی است؛ پس برای شروع استفاده از نسخه خودتان به‌جای نسخه سراسریِ پیش‌فرض، به‌روزرسانی زیادی در برنامه‌تان لازم نیست. برای به‌روزرسانی برنامه به استفاده از http.ServeMux، فایل main.go را دوباره باز کرده و برنامه‌تان را به‌روزرسانی کنید:

main.go

...

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", getRoot)
    mux.HandleFunc("/hello", getHello)

    err := http.ListenAndServe(":3333", mux)

    ...
}

در این به‌روزرسانی، یک http.ServeMux جدید با استفاده از سازنده http.NewServeMux ساختید و آن را به متغیر mux اختصاص دادید. بعد از آن، فقط لازم بود فراخوانی‌های http.HandleFunc را به استفاده از متغیر mux به‌جای فراخوانی پکیج http به‌روزرسانی کنید. در نهایت، فراخوانی http.ListenAndServe را به‌روزرسانی کردید تا http.Handler ساخته‌شده خودتان (‏mux) را به‌جای مقدار nil به آن بدهید.

حالا می‌توانید برنامه خود را دوباره با go run اجرا کنید:

go run main.go

برنامه شما مثل دفعه قبل به اجرا ادامه می‌دهد؛ پس باید دستورات تعامل با سرور را در ترمینال دیگری اجرا کنید. ابتدا، با curl دوباره مسیر / را درخواست کنید:

curl http://localhost:3333

خروجی به شکل زیر خواهد بود:

خروجی:

This is my website!

می‌بینید که این خروجی مثل قبل است.

سپس، همان دستور قبلی را برای مسیر /hello اجرا کنید:

curl http://localhost:3333/hello

خروجی به این شکل خواهد بود:

خروجی:

Hello, HTTP!

خروجی این مسیر هم مثل قبل است.

در نهایت، اگر به ترمینال اولیه برگردید، خروجی هر دو درخواست / و /hello را مثل قبل می‌بینید:

خروجی:

got / request
got /hello request

به‌روزرسانی‌ای که روی برنامه اعمال کردید از نظر عملکردی همان است؛ اما این بار از http.Handler خودتان به‌جای هندلر پیش‌فرض استفاده می‌کنید.

در نهایت، CONTROL+C را دوباره فشار دهید تا از برنامه سرور خود خارج شوید.

ServeMux و روترهای شخص ثالث

در بخش بالا، برنامه خود را به‌روزرسانی کردید تا به‌جای مالتی‌پلکسر سرور پیش‌فرض از http.ServeMux استفاده کند. نوع ServeMux که توسط پکیج net/http فراهم می‌شود، راهی ساده و مؤثر برای هدایت درخواست‌های HTTP ورودی به توابع هندلر بر اساس مسیر درخواست است.

ServeMux با تطبیق دادن مسیر URL یک درخواست ورودی با یک الگوی (Pattern) ثبت‌شده و سپس فراخوانی هندلر مربوطه کار می‌کند. چون بخشی از کتابخانه استاندارد Go است، به‌طور یکپارچه با نوع http.Server کار می‌کند و وابستگی اضافی نمی‌خواهد. همین موضوع آن را به انتخابی قوی برای اپلیکیشن‌های کوچک و یادگیری نحوه کار مسیریابی HTTP در Go تبدیل می‌کند.

با این حال، ServeMux در طراحی خود عمداً مینیمال است. این ابزار از این موارد پشتیبانی می‌کند:

  • تطبیق دقیق مسیر (Exact Path)
  • مسیریابی ساده مبتنی بر پیشوند (Prefix)

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

یک مثال رایج، نیاز به مسیرهای داینامیک یا پارامتری است. URLی مثل این را در نظر بگیرید:

/users/42

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

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

  • chi
  • gorilla/mux
  • echo
  • gin

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

  • مسیرهای پارامتری (مثلاً ‎/users/{id}‎)
  • گروه‌بندی و تو در تو کردن (Nesting) مسیرها
  • قوانین تطبیق درخواست انعطاف‌پذیرتر
  • ابزارهای کمکی داخلی برای اعمال میدل‌ور

مثلاً، یک روتر ممکن است به شما اجازه دهد مسیری مثل ‎/users/{id}‎ تعریف کنید و مقدار id را به‌طور خودکار از URL درخواست استخراج کند. این کار نیاز به تجزیه دستی را کاهش داده و منطق هندلر را روی پردازش درخواست متمرکز نگه می‌دارد.

مزیت دیگر بسیاری از روترهای شخص ثالث، پشتیبانی آن‌ها از میدل‌ور است. درحالی‌که میدل‌ور می‌تواند با اینترفیس http.Handler پیاده‌سازی شود — همان‌طور که قبلاً در این آموزش دیدید — روترها اغلب راه‌های convenientی برای اعمال میدل‌ور روی گروه‌هایی از مسیرها یا کل اپلیکیشن فراهم می‌کنند.

با وجود این قابلیت‌های اضافه، بیشتر روترهای شخص ثالث طوری طراحی شده‌اند که با پکیج استاندارد net/http یکپارچه شوند، نه اینکه جایگزین آن شوند. آن‌ها معمولاً اینترفیس http.Handler را پیاده‌سازی می‌کنند؛ یعنی می‌توان از آن‌ها مثل ServeMux با http.ListenAndServe یا http.Server استفاده کرد.

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

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

با درک هر دو رویکرد، می‌توانید راه‌حل مسیریابی‌ای را انتخاب کنید که بهترین تناسب را با اپلیکیشن شما دارد؛ درحالی‌که همچنان روی همان مفاهیم پایه‌ای که سرور HTTP در Go فراهم می‌کند، بنا می‌کنید.

اجرای چند سرور به‌صورت هم‌زمان

علاوه بر استفاده از http.Handler خودتان، پکیج net/http در Go به شما اجازه می‌دهد از سرور HTTPای غیر از سرور پیش‌فرض هم استفاده کنید. گاهی ممکن است بخواهید نحوه اجرای سرور را سفارشی کنید یا بخواهید چند سرور HTTP را هم‌زمان در یک برنامه اجرا کنید. مثلاً، ممکن است یک وب‌سایت عمومی و یک وب‌سایت ادمین خصوصی داشته باشید که بخواهید از یک برنامه اجرا شوند. از آنجا که فقط می‌توانید یک سرور HTTP پیش‌فرض داشته باشید، با سرور پیش‌فرض نمی‌توانستید این کار را انجام دهید. در این بخش، برنامه خود را برای استفاده از دو مقدار http.Server که توسط پکیج net/http فراهم می‌شوند، به‌روزرسانی می‌کنید؛ برای مواردی مثل این — وقتی کنترل بیشتری روی سرور می‌خواهید یا هم‌زمان به چند سرور نیاز دارید.

در فایل main.go خود، چند سرور HTTP را با http.Server راه‌اندازی می‌کنید. همچنین توابع هندلر خود را به‌روزرسانی می‌کنید تا به context.Context مربوط به *http.Request ورودی دسترسی داشته باشند. این کار به شما اجازه می‌دهد سروری که درخواست از آن آمده را در یک متغیر context.Context تنظیم کنید تا بتوانید سرور را در خروجی تابع هندلر چاپ کنید.

فایل main.go را دوباره باز کرده و آن را به شکل نشان‌داده‌شده به‌روزرسانی کنید:

main.go

package main

import (
    // توجه: ایمپورت "os" را نیز حذف کنید.
    "context"
    "errors"
    "fmt"
    "io"
    "net"
    "net/http"
)

const keyServerAddr = "serverAddr"

func getRoot(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    fmt.Printf("%s: got / request\n", ctx.Value(keyServerAddr))
    io.WriteString(w, "This is my website!\n")
}
func getHello(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr))
    io.WriteString(w, "Hello, HTTP!\n")
}

در به‌روزرسانی کد بالا، دستور ایمپورت را به‌روزرسانی کردید تا پکیج‌های موردنیاز برای این تغییر را در بر بگیرد. سپس، یک مقدار رشته‌ای ثابت (Const) با نام keyServerAddr ساختید تا به‌عنوان کلید مقدار آدرس سرور HTTP در context مربوط به http.Request عمل کند. در نهایت، هر دو تابع getRoot و getHello خود را به‌روزرسانی کردید تا به مقدار context.Context مربوط به http.Request دسترسی داشته باشند. وقتی مقدار را داشته باشید، آدرس سرور HTTP را در خروجی fmt.Printf می‌گنجانید تا ببینید کدام‌یک از دو سرور، درخواست HTTP را مدیریت کرده است.

حالا، به‌روزرسانی تابع main خود را با افزودن اولینِ دو مقدار http.Server شروع کنید:

main.go

...

func main() {
    ...
    mux.HandleFunc("/hello", getHello)

    ctx, cancelCtx := context.WithCancel(context.Background())
    serverOne := &http.Server{
        Addr:    ":3333",
        Handler: mux,
        BaseContext: func(l net.Listener) context.Context {
            ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String())
            return ctx
        },
    }

در کد به‌روزرسانی‌شده، اولین کاری که کردید ساخت یک مقدار جدید context.Context با یک تابع قابل‌استفاده برای لغو کانتکست (‏cancelCtx) بود. سپس، مقدار serverOne از نوع http.Server را تعریف کردید. این مقدار خیلی شبیه سرور HTTPای است که تا این لحظه استفاده می‌کردید؛ اما به‌جای پاس دادن آدرس و هندلر به تابع http.ListenAndServe، آن‌ها را به‌عنوان مقادیر Addr و Handler مربوط به http.Server تنظیم کردید.

یک تغییر دیگر، افزودن تابع BaseContext است. BaseContext راهی برای تغییر بخش‌هایی از context.Context است که توابع هندلر هنگام فراخوانی متد Context از *http.Request دریافت می‌کنند. در برنامه شما، آدرسی که سرور روی آن گوش می‌دهد (‏()l.Addr().String) را با کلید serverAddr به کانتکست اضافه می‌کنید که سپس در خروجی تابع هندلر چاپ می‌شود.

سپس، سرور دوم خود یعنی serverTwo را تعریف کنید:

main.go

...

func main() {
    ...
    serverOne := &http.Server {
        ...
    }

    serverTwo := &http.Server{
        Addr:    ":4444",
        Handler: mux,
        BaseContext: func(l net.Listener) context.Context {
            ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String())
            return ctx
        },
    }

این سرور به همان روش سرور اول تعریف شده است؛ به‌جز اینکه به‌جای مقدار ‎:3333 برای فیلد Addr، آن را روی ‎:4444 تنظیم کردید. به این ترتیب، یک سرور روی پورت 3333 به درخواست‌ها گوش می‌دهد و سرور دوم روی پورت 4444 گوش می‌دهد.

حالا، برنامه خود را به‌روزرسانی کنید تا سرور اول یعنی serverOne را در یک گوروتین راه‌اندازی کند:

main.go

...

func main() {
    ...
    serverTwo := &http.Server {
        ...
    }

    go func() {
        err := serverOne.ListenAndServe()
        if errors.Is(err, http.ErrServerClosed) {
            fmt.Printf("server one closed\n")
        } else if err != nil {
            fmt.Printf("error listening for server one: %s\n", err)
        }
        cancelCtx()
    }()

داخل گوروتین، سرور را با ListenAndServe راه‌اندازی می‌کنید؛ همان‌طور که قبلاً انجام می‌دادید، اما این بار نیازی به ارائه پارامترها به تابع مثل http.ListenAndServe نیست؛ چون مقادیر http.Server از قبل پیکربندی شده‌اند. سپس، همان مدیریت خطای قبل را انجام می‌دهید. در انتهای تابع، cancelCtx را برای لغو کانتکستی که به هندلرهای HTTP و هر دو تابع BaseContext سرورها ارائه می‌شود، فراخوانی می‌کنید. به این ترتیب، اگر سرور به دلیلی پایان یابد، کانتکست هم پایان می‌یابد.

در نهایت، برنامه خود را به‌روزرسانی کنید تا سرور دوم را هم در یک گوروتین راه‌اندازی کند:

main.go

...

func main() {
    ...
    go func() {
        ...
    }()
    go func() {
        err := serverTwo.ListenAndServe()
        if errors.Is(err, http.ErrServerClosed) {
            fmt.Printf("server two closed\n")
        } else if err != nil {
            fmt.Printf("error listening for server two: %s\n", err)
        }
        cancelCtx()
    }()

    <-ctx.Done()
}

این گوروتین از نظر عملکردی همان گوروتین اول است؛ فقط به‌جای serverOne، سرور serverTwo را راه‌اندازی می‌کند. این به‌روزرسانی شامل انتهای تابع main هم هست که در آن قبل از بازگشت از تابع main، از کانال ctx.Done می‌خوانید. این کار تضمین می‌کند برنامه شما تا زمانی که یکی از گوروتین‌های سرور پایان یابد و cancelCtx فراخوانی شود، به اجرا ادامه دهد. وقتی کانتکست پایان یابد، برنامه شما خارج می‌شود.

وقتی کارتان تمام شد، فایل را ذخیره کرده و ببندید.

سرور خود را با دستور go run اجرا کنید:

go run main.go

برنامه شما دوباره به اجرا ادامه می‌دهد؛ پس در ترمینال دوم خود، دستورات curl را برای درخواست مسیرهای / و /hello از سروری که روی 3333 گوش می‌دهد اجرا کنید؛ همانند درخواست‌های قبلی:

curl http://localhost:3333
curl http://localhost:3333/hello

خروجی به این شکل خواهد بود:

خروجی:

This is my website!
Hello, HTTP!

در خروجی، همان پاسخ‌هایی را می‌بینید که قبلاً دیدید.

حالا، همان دستورات را دوباره اجرا کنید؛ اما این بار از پورت 4444 استفاده کنید که با serverTwo در برنامه شما مطابقت دارد:

curl http://localhost:4444
curl http://localhost:4444/hello

خروجی به شکل زیر خواهد بود:

خروجی:

This is my website!
Hello, HTTP!

خروجی این درخواست‌ها را همان‌طور که برای درخواست‌های پورت 3333 که توسط serverOne سرو می‌شدند دیدید، خواهید دید.

در نهایت، به ترمینال اولیه‌ای که سرور شما در آن در حال اجراست نگاه کنید:

خروجی:

[::]:3333: got / request
[::]:3333: got /hello request
[::]:4444: got / request
[::]:4444: got /hello request

خروجی شبیه چیزی است که قبلاً دیدید؛ اما این بار سروری که به درخواست پاسخ داده را نشان می‌دهد. دو درخواست اول نشان می‌دهند از سروری آمده‌اند که روی پورت 3333 گوش می‌دهد (serverOne) و دو درخواست دوم از سروری آمده‌اند که روی پورت 4444 گوش می‌دهد (serverTwo). این همان مقادیری هستند که از مقدار serverAddr مربوط به BaseContext بازیابی شده‌اند.

بسته به اینکه کامپیوتر شما برای استفاده از IPv6 پیکربندی شده یا نه، خروجی شما ممکن است کمی با خروجی بالا متفاوت باشد. اگر باشد، همان خروجی بالا را می‌بینید. اگر نباشد، به‌جای [::] مقدار 0.0.0.0 را خواهید دید. دلیل این موضوع این است که کامپیوتر شما در صورت پیکربندی‌شدن، از طریق IPv6 با خودش ارتباط برقرار می‌کند و [::] نماد IPv6 برای 0.0.0.0 است.

وقتی کارتان تمام شد، دوباره از CONTROL+C برای توقف سرور استفاده کنید.

در این بخش، یک برنامه سرور HTTP جدید با http.HandleFunc و http.ListenAndServe برای اجرا و پیکربندی سرور پیش‌فرض ساختید. سپس، آن را به‌روزرسانی کردید تا از http.ServeMux به‌عنوان http.Handler به‌جای مالتی‌پلکسر سرور پیش‌فرض استفاده کند. در نهایت، برنامه خود را به‌روزرسانی کردید تا با http.Server چند سرور HTTP را در یک برنامه اجرا کند.

درحالی‌که الان یک سرور HTTP در حال اجرا دارید، تعاملیِ زیادی ندارد. می‌توانید مسیرهای جدیدی به آن اضافه کنید، اما راهی برای تعامل کاربران با آن فراتر از این وجود ندارد. پروتکل HTTP راه‌های زیادی برای تعامل کاربران با یک سرور HTTP فراتر از مسیرها در بر دارد. در بخش بعد، برنامه خود را به‌روزرسانی می‌کنید تا از اولین آن‌ها پشتیبانی کند: مقادیر رشته کوئری.

استفاده از میدل‌ور برای پردازش درخواست‌ها

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

برای رفع این مشکل، اپلیکیشن‌های Go معمولاً از الگویی به نام میدل‌ور (Middleware) استفاده می‌کنند. میدل‌ور به شما اجازه می‌دهد یک هندلر موجود را با قابلیت اضافه بپوشانید تا منطق مشترک به‌طور خودکار برای هر درخواست اجرا شود. این الگو در Go خوب کار می‌کند؛ چون همه هندلرهای HTTP از همان اینترفیس http.Handler پیروی می‌کنند که قبلاً در این آموزش معرفی شد.

ساخت یک تابع میدل‌ور

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

برای دیدن نحوه کار این موضوع، فایل main.go خود را باز کرده و تابع زیر را اضافه کنید:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Printf("started %s %s\n", r.Method, r.URL.Path)

        next.ServeHTTP(w, r)

        fmt.Printf("completed %s %s\n", r.Method, r.URL.Path)
    })
}

این تابع، هندلر دیگری را — که به آن next اشاره می‌کند — می‌پوشاند و هندلر جدیدی برمی‌گرداند. وقتی درخواستی دریافت می‌شود، میدل‌ور ابتدا پیامی چاپ می‌کند که نشان دهد درخواست شروع شده است. سپس ‎next.ServeHTTP(w, r)‎ را برای انتقال کنترل به هندلر اصلی فراخوانی می‌کند. پس از پایان پردازش درخواست توسط هندلر، میدل‌ور پیام دیگری چاپ می‌کند که نشان دهد درخواست کامل شده است.

فراخوانی next.ServeHTTP همان چیزی است که به درخواست اجازه می‌دهد در زنجیره هندلرها ادامه پیدا کند. اگر این فراخوانی حذف می‌شد، درخواست هرگز به تابع هندلر شما نمی‌رسید و هیچ پاسخی تولید نمی‌شد.

اعمال میدل‌ور روی سرور

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", getRoot)
    mux.HandleFunc("/hello", getHello) 

    loggedMux := loggingMiddleware(mux)

    err := http.ListenAndServe(":3333", loggedMux)
    if err != nil {
        fmt.Printf("error starting server: %s\n", err)
    }
}

در این مثال، متغیر mux همچنان مسیریابی درخواست‌ها را به توابع هندلر مناسب انجام می‌دهد. با این حال، به‌جای پاس دادن مستقیم mux به http.ListenAndServe، آن با loggingMiddleware پوشانده شده است. این یعنی هر درخواستی قبل از رسیدن به توابع هندلری که قبلاً تعریف کردید، از میدل‌ور عبور می‌کند.

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

زنجیره‌کردن چند تابع میدل‌ور

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

func headerMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-App-Version", "1.0")

        next.ServeHTTP(w, r)
    })
}

این میدل‌ور قبل از انتقال درخواست به هندلر بعدی، یک هدر پاسخ تنظیم می‌کند. برای اعمال هر دو تابع میدل‌ور، می‌توانید آن‌ها را به‌ترتیب دور هندلر خود بپیچانید:

handler := loggingMiddleware(headerMiddleware(mux))
http.ListenAndServe(":3333", handler)

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

این رویکرد لایه‌ای به شما اجازه می‌دهد رفتار مشترکی را به اپلیکیشن خود اضافه کنید، بدون اینکه هر هندلر را جداگانه تغییر دهید. میدل‌ور در اپلیکیشن‌های Go معمولاً برای هندل کردن مسائلی مانند لاگ‌گیری، احراز هویت و اعتبارسنجی درخواست استفاده می‌شود. چون روی اینترفیس http.Handler بنا شده، هم با کتابخانه استاندارد و هم با روترهای شخص ثالث به‌طور یکپارچه کار می‌کند.

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

بررسی رشته کوئری درخواست

یکی از راه‌هایی که کاربر می‌تواند روی پاسخ HTTPای که از یک سرور HTTP دریافت می‌کند تأثیر بگذارد، استفاده از رشته کوئری است. رشته کوئری مجموعه‌ای از مقادیر است که به انتهای URL اضافه می‌شود. با یک کاراکتر ? شروع می‌شود و مقادیر اضافی با & به‌عنوان جداکننده اضافه می‌شوند. مقادیر رشته کوئری معمولاً به‌عنوان راهی برای فیلتر یا سفارشی‌کردن نتایجی که یک سرور HTTP به‌عنوان پاسخ می‌فرستد استفاده می‌شوند. مثلاً، یک سرور ممکن است از یک مقدار results استفاده کند تا به کاربر اجازه دهد چیزی مثل results=10 را مشخص کند و بگوید می‌خواهد ۱۰ آیتم در فهرست نتایجش ببیند.

در این بخش، تابع هندلر getRoot خود را به‌روزرسانی می‌کنید تا با مقدار *http.Request خود، به مقادیر رشته کوئری دسترسی پیدا کرده و آن‌ها را در خروجی چاپ کند.

ابتدا، فایل main.go را باز کرده و تابع getRoot را به‌روزرسانی کنید تا با متد r.URL.Query به رشته کوئری دسترسی پیدا کند. سپس، متد main را به‌روزرسانی کنید تا serverTwo و تمام کدهای مرتبط با آن حذف شوند؛ چون دیگر به آن نیاز ندارید:

main.go

...

func getRoot(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    hasFirst := r.URL.Query().Has("first")
    first := r.URL.Query().Get("first")
    hasSecond := r.URL.Query().Has("second")
    second := r.URL.Query().Get("second")

    fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s\n",
        ctx.Value(keyServerAddr),
        hasFirst, first,
        hasSecond, second)
    io.WriteString(w, "This is my website!\n")
}
...

در تابع getRoot، از فیلد r.URL مربوط به *http.Request در getRoot برای دسترسی به مشخصات URL درخواست‌شده استفاده می‌کنید. سپس از متد Query مربوط به فیلد r.URL برای دسترسی به مقادیر رشته کوئری درخواست استفاده می‌کنید. وقتی به مقادیر رشته کوئری دسترسی دارید، دو متد برای تعامل با داده‌ها وجود دارد. متد Has یک مقدار bool برمی‌گرداند که مشخص می‌کند رشته کوئری مقداری با کلید داده‌شده — مانند first — دارد یا نه. سپس، متد Get یک رشته با مقدارِ کلید داده‌شده برمی‌گرداند.

در تئوری، همیشه می‌توانستید از متد Get برای بازیابی مقادیر رشته کوئری استفاده کنید؛ چون همیشه یا مقدار واقعی کلید داده‌شده را برمی‌گرداند یا در صورت وجودنداشتن کلید، یک رشته خالی. برای بسیاری از کاربردها این کافی است — اما در برخی موارد ممکن است بخواهید تفاوت بین کاربری که مقدار خالی ارائه کرده و کاربری که اصلاً مقداری ارائه نکرده را بدانید. بسته به مورد استفاده شما، ممکن است بخواهید بدانید کاربر مقدار فیلترِ خالی داده است یا اصلاً فیلتری نداده است. اگر مقدار فیلترِ خالی داده باشد، ممکن است بخواهید آن را به‌عنوان «هیچ چیزی به من نشان نده» در نظر بگیرید؛ در حالی که ندادن فیلتر یعنی «همه چیز را به من نشان بده». استفاده از Has و Get به شما اجازه می‌دهد تفاوت این دو حالت را تشخیص دهید.

در تابع getRoot خود، خروجی را هم به‌روزرسانی کردید تا مقادیر Has و Get برای هر دو مقدار رشته کوئریِ first و second را نشان دهد.

حالا، تابع main خود را به‌روزرسانی کنید تا دوباره از یک سرور استفاده کند:

main.go

...

func main() {
    ...
    mux.HandleFunc("/hello", getHello)

    ctx := context.Background()
    server := &http.Server{
        Addr:    ":3333",
        Handler: mux,
        BaseContext: func(l net.Listener) context.Context {
            ctx = context.WithValue(ctx, keyServerAddr, l.Addr().String())
            return ctx
        },
    }

    err := server.ListenAndServe()
    if errors.Is(err, http.ErrServerClosed) {
        fmt.Printf("server closed\n")
    } else if err != nil {
        fmt.Printf("error listening for server: %s\n", err)
    }
}

در تابع main، ارجاعات به serverTwo را حذف کردید و اجرای سرور (که قبلاً serverOne بود) را از یک گوروتین به داخل تابع main منتقل کردید؛ مشابه روشی که قبلاً http.ListenAndServe را اجرا می‌کردید. می‌توانستید به‌جای استفاده از مقدار http.Server، آن را دوباره به http.ListenAndServe تغییر دهید؛ چون دوباره فقط یک سرور در حال اجرا دارید، اما با استفاده از http.Server، اگر در آینده بخواهید سفارشی‌سازی‌های اضافی روی سرور اعمال کنید، کار کمتری برای به‌روزرسانی خواهید داشت.

حالا، پس از ذخیره تغییرات، با go run برنامه خود را دوباره اجرا کنید:

go run main.go

سرور شما دوباره شروع به کار می‌کند؛ پس به ترمینال دوم خود برگردید تا دستور curl را همراه با یک رشته کوئری اجرا کنید. در این دستور، باید URL خود را با تک‌کوتیشن (‘) بپوشانید؛ در غیر این صورت، شلِ ترمینال شما ممکن است نماد & در رشته کوئری را به‌عنوان قابلیت «اجرای این دستور در پس‌زمینه» که بسیاری از شل‌ها دارند، تفسیر کند. در URL، مقداری از first برابر first=1 و مقدار second برابر second= قرار دهید:

curl 'http://localhost:3333?first=1&second='

خروجی به این شکل خواهد بود:

خروجی:

This is my website!

می‌بینید که خروجی دستور curl نسبت به درخواست‌های قبلی تغییری نکرده است.

با این حال، اگر به خروجی برنامه سرور خود برگردید، می‌بینید که خروجی جدید شامل مقادیر رشته کوئری است:

خروجی:

[::]:3333: got / request. first(true)=1, second(true)=

خروجیِ مقدار اول رشته کوئری نشان می‌دهد متد Has مقدار true را برگردانده؛ چون first مقداری دارد، و همچنین اینکه Get مقدار ۱ را برگردانده است. خروجیِ second نشان می‌دهد که Has مقدار true را برگردانده؛ چون second لحاظ شده، اما متد Get چیزی غیر از یک رشته خالی برنگردانده است. همچنین می‌توانید با افزودن و حذف first و second یا تنظیم مقادیر متفاوت، درخواست‌های مختلفی امتحان کنید تا ببینید خروجی این توابع چطور تغییر می‌کند.

وقتی کارتان تمام شد، CONTROL+C را فشار دهید تا سرور شما متوقف شود.

در این بخش، برنامه خود را به‌روزرسانی کردید تا دوباره فقط از یک http.Server استفاده کند؛ اما پشتیبانی خواندن مقادیر first و second از رشته کوئری برای تابع هندلر getRoot را هم اضافه کردید.

با این حال، استفاده از رشته کوئری تنها راه کاربران برای ارائه ورودی به یک سرور HTTP نیست. روش رایج دیگر برای ارسال داده به سرور، قرار دادن داده در بدنه درخواست است. در بخش بعد، برنامه خود را به‌روزرسانی می‌کنید تا بدنه درخواست را از داده‌های *http.Request بخواند.

خواندن بدنه درخواست

هنگام ساخت یک API مبتنی بر HTTP — مانند REST API — ممکن است کاربر نیاز به ارسال داده‌ای بیش از آنچه در محدودیت‌های طول URL جا می‌شود داشته باشد، یا صفحه شما نیاز به دریافت داده‌ای داشته باشد که درباره نحوه تفسیر داده نیست؛ مانند فیلترها یا محدودیت‌های نتایج. در این موارد، معمول است که داده را در بدنه درخواست قرار داده و با درخواست HTTP از نوع POST یا PUT ارسال کرد.

در یک http.HandlerFunc در Go، مقدار *http.Request برای دسترسی به اطلاعات درخواست ورودی استفاده می‌شود و همچنین راهی برای دسترسی به بدنه درخواست از طریق فیلد Body در بر دارد. در این بخش، تابع هندلر getRoot خود را به‌روزرسانی می‌کنید تا بدنه درخواست را بخواند.

برای به‌روزرسانی متد getRoot خود، فایل main.go را باز کرده و آن را به‌روزرسانی کنید تا از io.ReadAll برای خواندن فیلد درخواست r.Body استفاده کند:

main.go

package main

import (
    ...
    "io"
    ...
)

...

func getRoot(w http.ResponseWriter, r *http.Request) {
    ...
    second := r.URL.Query().Get("second")

    body, err := io.ReadAll(r.Body)
    if err != nil {
        fmt.Printf("could not read body: %s\n", err)
    }

    fmt.Printf("%s: got / request. first(%t)=%s, second(%t)=%s, body:\n%s\n",
        ctx.Value(keyServerAddr),
        hasFirst, first,
        hasSecond, second,
        body)
    io.WriteString(w, "This is my website!\n")
}

...

در این به‌روزرسانی، از تابع io.ReadAll برای خواندن مشخصه r.Body مربوط به *http.Request و دسترسی به بدنه درخواست استفاده می‌کنید. تابع io.ReadAll یک تابع کمکی است که داده‌ها را از یک io.Reader می‌خواند تا وقتی به خطا یا انتهای داده برسد. از آنجا که r.Body یک io.Reader است، می‌توانید از آن برای خواندن بدنه استفاده کنید. پس از خواندن بدنه، fmt.Printf را هم به‌روزرسانی کردید تا آن را در خروجی چاپ کند.

پس از ذخیره به‌روزرسانی‌ها، سرور خود را با دستور go run اجرا کنید:

go run main.go

چون سرور تا وقتی آن را متوقف نکنید به کار ادامه می‌دهد، به ترمینال دیگر خود بروید تا با curl و با گزینه -X POST یک درخواست POST و با گزینه -d یک بدنه ارسال کنید. می‌توانید مقادیر رشته کوئری first و second قبلی را هم استفاده کنید:

curl -X POST -d 'This is the body' 'http://localhost:3333?first=1&second='

خروجی شما به شکل زیر خواهد بود:

خروجی:

This is my website!

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

خروجی:

[::]:3333: got / request. first(true)=1, second(true)=, body:
This is the body

در لاگ‌های سرور، مقادیر رشته کوئری قبلی را می‌بینید؛ اما حالا داده This is the body را که دستور curl فرستاده هم می‌بینید.

حالا، با فشردن CONTROL+C سرور را متوقف کنید.

در این بخش، برنامه خود را به‌روزرسانی کردید تا بدنه درخواست را در متغیری بخواند و آن را در خروجی چاپ کنید. با ترکیب خواندن بدنه به این روش و قابلیت‌های دیگر — مانند encoding/json برای تبدیل (Unmarshal) یک بدنه JSON به داده‌های Go — خواهید توانست APIهایی بسازید که کاربران شما بتوانند به شکلی که از APIهای دیگر می‌شناسند با آن‌ها تعامل کنند.

البته همه داده‌های ارسالی از سمت کاربر به شکل API نیستند. بسیاری از وب‌سایت‌ها فرم‌هایی دارند که از کاربران می‌خواهند پر کنند؛ پس در بخش بعد، برنامه خود را به‌روزرسانی می‌کنید تا علاوه بر بدنه درخواست و رشته کوئری که از قبل دارید، داده‌های فرم را هم بخواند.

بازیابی داده‌های فرم

برای مدت طولانی، ارسال داده با استفاده از فرم‌ها روش استاندارد کاربران برای ارسال داده به یک سرور HTTP و تعامل با یک وب‌سایت بود. فرم‌ها الان به محبوبیت گذشته خود نیستند، اما همچنان کاربردهای زیادی به‌عنوان راهی برای ارسال داده کاربران به یک وب‌سایت دارند. مقدار *http.Request در http.HandlerFunc نیز راهی برای دسترسی به این داده‌ها فراهم می‌کند؛ مشابه نحوه فراهم‌کردن دسترسی به رشته کوئری و بدنه درخواست. در این بخش، برنامه خود (تابع getHello) را به‌روزرسانی می‌کنید تا نام کاربر را از یک فرم دریافت کرده و با نام او پاسخ بدهد.

فایل main.go خود را باز کرده و تابع getHello را به‌روزرسانی کنید تا از متد PostFormValue مربوط به *http.Request استفاده کند:

main.go

...

func getHello(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr))

    myName := r.PostFormValue("myName")
    if myName == "" {
        myName = "HTTP"
    }
    io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName))
}

...

حالا در تابع getHello خود، مقادیر فرمی که به تابع هندلر شما پست شده را می‌خوانید و به‌دنبال مقداری با نام myName می‌گردید. اگر مقدار پیدا نشود یا مقدارِ پیدا شده یک رشته خالی باشد، متغیر myName را روی مقدار پیش‌فرض HTTP تنظیم می‌کنید تا صفحه نامِ خالی نمایش ندهد. سپس، خروجی کاربر را به‌روزرسانی کردید تا نامی که فرستاده — یا HTTP اگر نامی نفرستاده — نمایش داده شود.

برای اجرای سرور با این به‌روزرسانی‌ها، تغییرات را ذخیره کرده و با go run اجرا کنید:

go run main.go

حالا، در ترمینال دوم خود، از curl با گزینه -X POST برای URL مسیر /hello استفاده کنید؛ اما این بار به‌جای استفاده از -d برای ارائه بدنه داده، از گزینه ‎-F ‘myName=Sammy’‎ برای ارائه داده‌های فرم با فیلد myName و مقدار Sammy استفاده کنید:

curl -X POST -F 'myName=Sammy' 'http://localhost:3333/hello'

خروجی به این شکل خواهد بود:

خروجی:

Hello, Sammy!

در خروجی بالا، سلام موردانتظار Hello, Sammy! را می‌بینید؛ چون فرمی که با curl فرستادید گفت myName برابر Sammy است.

متد r.PostFormValue که در تابع getHello برای بازیابی مقدار فرم myName استفاده می‌کنید، متدی خاص است که فقط مقادیر پست‌شده از یک فرم در بدنه درخواست را شامل می‌شود. با این حال، متد r.FormValue هم موجود است که هم بدنه فرم و هم مقادیر رشته کوئری را در بر می‌گیرد. پس اگر از ‎r.FormValue(“myName”)‎ استفاده می‌کردید، می‌توانستید گزینه -F را حذف کرده و myName=Sammy را در رشته کوئری قرار دهید تا Sammy را هم به‌عنوان پاسخ ببینید. اگر این کار را بدون تغییر به r.FormValue می‌کردید، پاسخ پیش‌فرض HTTP برای نام را می‌دیدید. دقت درباره اینکه از کجا این مقادیر را بازیابی می‌کنید، می‌تواند از تداخل‌های احتمالی در نام‌ها یا باگ‌هایی که ردیابی‌شان سخت است جلوگیری کند. مفید است که سخت‌گیرانه‌تر باشیم و از r.PostFormValue استفاده کنیم؛ مگر اینکه انعطاف قرار دادن آن در رشته کوئری را هم بخواهید.

اگر به لاگ‌های سرور خود برگردید، می‌بینید که درخواست /hello مشابه درخواست‌های قبلی لاگ شده است:

خروجی:

[::]:3333: got /hello request

برای توقف سرور، CONTROL+C را فشار دهید.

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

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

پاسخ‌دادن با هدرها و کد وضعیت

پروتکل HTTP از چند قابلیت که کاربران معمولاً نمی‌بینند برای ارسال داده و کمک به مرورگرها یا سرورها برای ارتباط استفاده می‌کند. یکی از این قابلیت‌ها کد وضعیت (Status Code) نام دارد و توسط سرور برای دادن درک بهتری به کلاینت HTTP استفاده می‌شود؛ اینکه سرور درخواست را موفق می‌داند یا اینکه در سمت سرور یا در درخواستی که کلاینت فرستاده مشکلی پیش آمده است.

راه دیگر ارتباط سرورها و کلاینت‌های HTTP با هم، استفاده از فیلدهای هدر است. یک فیلد هدر یک کلید و مقدار است که کلاینت یا سرور به دیگری می‌فرستد تا درباره خودش به او اطلاعات بدهد. هدرهای زیادی وجود دارند که توسط پروتکل HTTP از پیش تعریف شده‌اند؛ مانند Accept که کلاینت با آن به سرور می‌گوید چه نوع داده‌ای را می‌تواند بپذیرد و بفهمد. همچنین می‌توان هدرهای خودتان را با پیشوند x- و سپس بقیه یک نام تعریف کرد.

در این بخش، برنامه خود را به‌روزرسانی می‌کنید تا فیلد فرم myName در getHello یک فیلد الزامی شود. اگر برای فیلد myName مقداری ارسال نشود، سرور شما کد وضعیت «Bad Request» را به کلاینت برمی‌گرداند و یک هدر x-missing-field اضافه می‌کند تا کلاینت بداند کدام فیلد جا افتاده است.

برای افزودن این قابلیت به برنامه خود، فایل main.go را یک بار دیگر باز کرده و بررسی اعتبارسنجی را به تابع هندلر getHello اضافه کنید:

main.go

...

func getHello(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    fmt.Printf("%s: got /hello request\n", ctx.Value(keyServerAddr))

    myName := r.PostFormValue("myName")
    if myName == "" {
        w.Header().Set("x-missing-field", "myName")
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    io.WriteString(w, fmt.Sprintf("Hello, %s!\n", myName))
}

در این به‌روزرسانی، اگر مقدار myName خالی باشد، ابتدا با ‎w.Header().Set‎ هدری سفارشی با نام x-missing-field و مقدار myName تنظیم می‌کنید تا کلاینت بداند کدام فیلد ارسال نشده است. سپس با ‎w.WriteHeader(http.StatusBadRequest)‎ کد وضعیت «400 Bad Request» را برای پاسخ تنظیم می‌کنید. در نهایت با return از ادامه تابع خارج می‌شوید تا در نبودِ مقدار myName، پاسخ پیش‌فرض خوش‌آمدگویی ارسال نشود.

نکته مهم این است که هدرها را باید قبل از فراخوانی w.WriteHeader تنظیم کنید. پس از ارسال کد وضعیت و بدنه به کلاینت، دیگر نمی‌توان هدرها را تغییر داد.

تغییرات را ذخیره کرده و سرور را دوباره اجرا کنید:

go run main.go

حالا در ترمینال دوم، با curl یک درخواست POST به مسیر /hello بفرستید؛ اما این بار بدون فیلد myName. برای دیدن هدرها و کد وضعیت در پاسخ، از فلگ -i استفاده کنید:

curl -i -X POST 'http://localhost:3333/hello'

خروجی:

HTTP/1.1 400 Bad Request
X-Missing-Field: myName
Date: Mon, 01 Jan 2024 00:00:00 GMT
Content-Length: 0

در خروجی، کد وضعیت 400 Bad Request و هدر X-Missing-Field را می‌بینید که به کلاینت نشان می‌دهد فیلد myName ارسال نشده است.

حالا همان درخواست را این بار همراه با فیلد myName تکرار کنید:

curl -X POST -F 'myName=Sammy' 'http://localhost:3333/hello'

خروجی:

Hello, Sammy!

این بار چون فیلد myName مقدار دارد، پاسخ معمول همراه با کد وضعیت پیش‌فرض 200 OK و پیام خوش‌آمدگویی دریافت می‌کنید.

وقتی کارتان تمام شد، با فشردن CONTROL+C سرور را متوقف کنید.

در این بخش، تابع هندلر getHello خود را به‌روزرسانی کردید تا فیلد فرم myName را الزامی کند و در صورت ارسال‌نشدن آن، با هدر سفارشی و کد وضعیت خطا به کلاینت پاسخ دهد.

جمع‌بندی

در این آموزش، با استفاده از کتابخانه استاندارد Go یک سرور HTTP ساختید. با http.HandleFunc و http.ListenAndServe سرور پیش‌فرض را راه‌اندازی کردید؛ سپس با http.ServeMux مالتی‌پلکسر اختصاصی خود را ساختید و در نهایت با http.Server چند سرور HTTP را هم‌زمان در یک برنامه اجرا کردید و از context برای انتقال اطلاعات سرور به هندلرها استفاده کردید. همچنین با پیاده‌سازی میدل‌ور برای پردازش قابل‌استفاده مجدد درخواست‌ها، سرور خود را گسترش دادید و با خواندن داده‌ها از رشته کوئری، بدنه درخواست و فرم‌ها، امکان تعامل کاربران با سرور را فراهم کردید. در پایان، هندلر خود را به‌روزرسانی کردید تا با هدرهای سفارشی و کدهای وضعیت HTTP پاسخ دهد.

این مبانی، پایه‌ای است برای گام‌های بعدی مانند ساخت Endpointهای JSON API، اعمال Timeoutهای سرور، پیاده‌سازی خاموش‌شدن منظم (Graceful Shutdown) و پیکربندی HTTPS. با ترکیب این مفاهیم با مدل هم‌زمانی مبتنی بر گوروتین در Go، می‌توانید سرورهایی با کارایی بالا بسازید که هزاران اتصال هم‌زمان را به‌طور کارآمد مدیریت می‌کنند — همه این‌ها بدون هیچ وابستگی خارجی و فقط با کتابخانه استاندارد Go.

نوشته های مشابه

دیدگاهتان را بنویسید

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

دکمه بازگشت به بالا