نقاط پایانی API و مسیریابی RESTful
در چند بخش بعدی کتاب، API خودمان را بهتدریج کامل میکنیم تا endpointها کمکم به این شکل دربیایند:
| عملیات | Handler | الگوی URL | متد |
|---|---|---|---|
| نمایش اطلاعات برنامه | healthcheckHandler | /v1/healthcheck | GET |
| نمایش جزئیات همه فیلمها | listMoviesHandler | /v1/movies | GET |
| ایجاد یک فیلم جدید | createMovieHandler | /v1/movies | POST |
| نمایش جزئیات یک فیلم مشخص | showMovieHandler | /v1/movies/:id | GET |
| بهروزرسانی جزئیات یک فیلم مشخص | editMovieHandler | /v1/movies/:id | PUT |
| حذف یک فیلم مشخص | deleteMovieHandler | /v1/movies/:id | DELETE |
اگر قبلا APIهایی با endpointهای سبک REST ساخته باشید، جدول بالا احتمالا برایتان کاملا آشناست و توضیح زیادی لازم ندارد. اما اگر با این موضوع تازه آشنا میشوید، چند نکته مهم وجود دارد که باید به آنها اشاره کنیم.
نکته اول این است که درخواستهایی با الگوی URL یکسان، بر اساس متد درخواست HTTP به handlerهای متفاوتی هدایت میشوند. هم از نظر امنیت و هم از نظر درستی معنایی، مهم است که برای عملیاتی که handler انجام میدهد از متد HTTP مناسب استفاده کنیم.
به طور خلاصه:
| کاربرد | متد |
|---|---|
| برای عملیاتی استفاده میشود که فقط اطلاعات را دریافت میکنند و وضعیت برنامه یا دادهای را تغییر نمیدهند. | GET |
برای عملیات non-idempotent که وضعیت را تغییر میدهند استفاده میشود. در زمینه یک REST API، معمولا از POST برای عملیاتی استفاده میشود که یک resource جدید ایجاد میکنند. |
POST |
برای عملیات idempotent که وضعیت یک resource در یک URL مشخص را تغییر میدهند استفاده میشود. در زمینه یک REST API، معمولا از PUT برای عملیاتی استفاده میشود که یک resource موجود را جایگزین یا بهروزرسانی میکنند. |
PUT |
| برای عملیاتی استفاده میشود که یک resource در یک URL مشخص را به صورت جزئی بهروزرسانی میکنند. این عملیات میتواند idempotent یا non-idempotent باشد. | PATCH |
| برای عملیاتی استفاده میشود که یک resource در یک URL مشخص را حذف میکنند. | DELETE |
نکته مهم دیگر این است که endpointهای API ما از clean URL استفاده میکنند و پارامترهای wildcard داخل مسیر URL قرار میگیرند. بنابراین، برای مثال، برای دریافت جزئیات یک فیلم مشخص، کلاینت به جای اینکه شناسه فیلم را به صورت پارامتر query string مثل GET /v1/movies?id=1 اضافه کند، درخواستی مثل GET /v1/movies/1 ارسال میکند.
انتخاب router
در این کتاب، به جای استفاده از http.ServeMux از کتابخانه استاندارد، از پکیج شخص ثالث محبوب httprouter به عنوان router برنامه استفاده میکنیم.
برای این کار دو دلیل داریم:
میخواهیم API ما تا جای ممکن به شکل یکدست پاسخهای JSON ارسال کند. متاسفانه وقتی route مطابقی پیدا نشود،
http.ServeMuxپاسخهای plain text، یعنی غیر JSON، برای404و405ارسال میکند و سفارشیسازی آسان آنها بدون ایجاد اثر جانبی که ارسال خودکار پاسخهای405را مختل کند ممکن نیست. یک پیشنهاد باز برای بهبود این وضعیت در نسخههای آینده Go وجود دارد، اما فعلا این یک ضعف نسبتا مهم است.علاوه بر این،
http.ServeMuxدرخواستهایOPTIONSرا به صورت خودکار مدیریت نمیکند.
httprouter هر دو مورد بالا را پشتیبانی میکند و در کنار آن همه قابلیتهای دیگری را که لازم داریم هم فراهم میکند. خود پکیج httprouter پایدار و خوب تستشده است و به عنوان یک مزیت اضافه، به لطف استفاده از radix tree برای تطبیق URL، بسیار سریع هم هست. اگر در حال ساخت یک JSON REST API عمومی هستید، httprouter انتخاب محکمی است و در مجموع، در حال حاضر گزینه بهتری نسبت به http.ServeMux باقی میماند.
اگر همراه کتاب کدنویسی میکنید، لطفا از go get استفاده کنید تا آخرین نسخه v1.N.N از httprouter را به این شکل دانلود کنید:
$ go get github.com/julienschmidt/httprouter@v1 go: downloading github.com/julienschmidt/httprouter v1.3.0 go get: added github.com/julienschmidt/httprouter v1.3.0
برای نمایش نحوه کار httprouter، کار را با اضافه کردن دو endpoint برای ایجاد یک فیلم جدید و نمایش جزئیات یک فیلم مشخص به codebase خود شروع میکنیم. تا پایان این فصل، endpointهای API ما به این شکل خواهند بود:
| عملیات | Handler | الگوی URL | متد |
|---|---|---|---|
| نمایش اطلاعات برنامه | healthcheckHandler | /v1/healthcheck | GET |
| ایجاد یک فیلم جدید | createMovieHandler | /v1/movies | POST |
| نمایش جزئیات یک فیلم مشخص | showMovieHandler | /v1/movies/:id | GET |
کپسولهسازی routeهای API
برای اینکه با بزرگتر شدن API تابع main() شلوغ نشود، همه قوانین routing را در یک فایل جدید به نام cmd/api/routes.go کپسوله میکنیم.
اگر همراه کتاب پیش میروید، این فایل جدید را ایجاد کنید و کد زیر را به آن اضافه کنید:
$ touch cmd/api/routes.go
package main import ( "net/http" "github.com/julienschmidt/httprouter" ) func (app *application) routes() http.Handler { // Initialize a new httprouter router instance. router := httprouter.New() // Register the relevant methods, URL patterns and handler functions for our // endpoints using the HandlerFunc() method. Note that http.MethodGet and // http.MethodPost are constants which equate to the strings "GET" and "POST" // respectively. router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) // Return the httprouter instance. return router }
کپسوله کردن قوانین routing به این شکل چند مزیت دارد. مزیت اول این است که تابع main() ما را تمیز نگه میدارد و مطمئن میشود همه routeها در یک جای واحد تعریف شدهاند. مزیت بزرگ دیگر، که در کتاب اول Let’s Go آن را نشان دادیم، این است که حالا میتوانیم در هر کد تستی بهراحتی با initialize کردن یک نمونه application و فراخوانی متد routes() روی آن، به router دسترسی داشته باشیم.
کار بعدی این است که تابع main() را بهروزرسانی کنیم تا تعریف http.ServeMux را حذف کند و به جای آن، نمونه httprouter برگرداندهشده توسط app.routes() را به عنوان handler سرور استفاده کند. به این شکل:
package main ... func main() { var cfg config flag.IntVar(&cfg.port, "port", 4000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) app := &application{ config: cfg, logger: logger, } // Use the httprouter instance returned by app.routes() as the server handler. srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.port), Handler: app.routes(), IdleTimeout: time.Minute, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), } logger.Info("starting server", "addr", srv.Addr, "env", cfg.env) err := srv.ListenAndServe() logger.Error(err.Error()) os.Exit(1) }
اضافه کردن handler functionهای جدید
حالا که قوانین routing تنظیم شدهاند، باید متدهای createMovieHandler و showMovieHandler را برای endpointهای جدید بسازیم. showMovieHandler در اینجا بهخصوص جالب است، چون میخواهیم در آن پارامتر شناسه فیلم را از URL استخراج کنیم و در پاسخ HTTP استفاده کنیم.
یک فایل جدید به نام cmd/api/movies.go ایجاد کنید تا این دو handler جدید را در خود نگه دارد:
$ touch cmd/api/movies.go
و سپس کد زیر را به آن اضافه کنید:
package main import ( "fmt" "net/http" "strconv" "github.com/julienschmidt/httprouter" ) // Add a createMovieHandler for the "POST /v1/movies" endpoint. For now we simply // return a plain-text placeholder response. func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "create a new movie") } // Add a showMovieHandler for the "GET /v1/movies/:id" endpoint. For now, we retrieve // the interpolated "id" parameter from the current URL and include it in a placeholder // response. func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { // When httprouter is parsing a request, any interpolated URL parameters will be // stored in the request context. We can use the ParamsFromContext() function to // retrieve a slice containing these parameter names and values. params := httprouter.ParamsFromContext(r.Context()) // We can then use the ByName() method to get the value of the "id" parameter from // the slice. In our project all movies will have a unique positive integer ID, but // the value returned by ByName() is always a string. So we try to convert it to a // base 10 integer (with a bit size of 64). If the parameter couldn't be converted, // or is less than 1, we know the ID is invalid so we use the http.NotFound() // function to return a 404 Not Found response. id, err := strconv.ParseInt(params.ByName("id"), 10, 64) if err != nil || id < 1 { http.NotFound(w, r) return } // Otherwise, interpolate the movie ID in a placeholder response. fmt.Fprintf(w, "show the details of movie %d\n", id) }
و با این کار، حالا آمادهایم آن را امتحان کنیم.
برنامه API را دوباره راهاندازی کنید...
$ go run ./cmd/api time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
سپس در حالی که سرور در حال اجراست، یک پنجره ترمینال دوم باز کنید و با استفاده از curl چند درخواست به endpointهای مختلف بفرستید. اگر همه چیز درست تنظیم شده باشد، پاسخهایی شبیه این خواهید دید:
$ curl localhost:4000/v1/healthcheck status: available environment: development version: 1.0.0 $ curl -X POST localhost:4000/v1/movies create a new movie $ curl localhost:4000/v1/movies/123 show the details of movie 123
دقت کنید که در مثال آخر، مقدار پارامتر id فیلم، یعنی 123، با موفقیت از URL خوانده شده و در پاسخ قرار گرفته است.
شاید بخواهید چند درخواست هم به یک URL مشخص با استفاده از یک متد HTTP پشتیبانینشده بفرستید. برای مثال، بیایید یک درخواست POST به /v1/healthcheck ارسال کنیم:
$ curl -i -X POST localhost:4000/v1/healthcheck HTTP/1.1 405 Method Not Allowed Allow: GET, OPTIONS Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff Date: Tue, 06 Apr 2021 06:59:04 GMT Content-Length: 19 Method Not Allowed
این خیلی خوب به نظر میرسد. پکیج httprouter به صورت خودکار یک پاسخ 405 Method Not Allowed برای ما ارسال کرده است، همراه با یک header به نام Allow که متدهای HTTP پشتیبانیشده برای آن endpoint را فهرست میکند.
به همین شکل، میتوانید یک درخواست OPTIONS به یک URL مشخص بفرستید و httprouter پاسخی همراه با header مربوط به Allow برمیگرداند که متدهای HTTP پشتیبانیشده را مشخص میکند. مثل این:
$ curl -i -X OPTIONS localhost:4000/v1/healthcheck HTTP/1.1 200 OK Allow: GET, OPTIONS Date: Tue, 06 Apr 2021 07:01:29 GMT Content-Length: 0
در نهایت، شاید بخواهید یک درخواست به endpoint مربوط به GET /v1/movies/:id با یک عدد منفی یا مقدار غیرعددی برای id در URL ارسال کنید. نتیجه باید یک پاسخ 404 Not Found شبیه این باشد:
$ curl -i localhost:4000/v1/movies/abc HTTP/1.1 404 Not Found Content-Type: text/plain; charset=utf-8 X-Content-Type-Options: nosniff Date: Tue, 06 Apr 2021 07:02:01 GMT Content-Length: 19 404 page not found
ایجاد helper برای خواندن پارامترهای ID
کدی که پارامتر id را از URLای مثل /v1/movies/:id استخراج میکند، چیزی است که بارها در برنامه به آن نیاز خواهیم داشت؛ پس منطق آن را در یک helper method کوچک و قابل استفاده مجدد abstract میکنیم.
یک فایل جدید به نام cmd/api/helpers.go ایجاد کنید:
$ touch cmd/api/helpers.go
و یک متد جدید به نام readIDParam() به struct مربوط به application اضافه کنید، به این شکل:
package main import ( "errors" "net/http" "strconv" "github.com/julienschmidt/httprouter" ) // Retrieve the "id" URL parameter from the current request context, then convert it to // an integer and return it. If the operation isn't successful, return 0 and an error. func (app *application) readIDParam(r *http.Request) (int64, error) { params := httprouter.ParamsFromContext(r.Context()) id, err := strconv.ParseInt(params.ByName("id"), 10, 64) if err != nil || id < 1 { return 0, errors.New("invalid id parameter") } return id, nil }
با وجود این helper method، حالا کد داخل showMovieHandler میتواند بسیار سادهتر شود:
package main import ( "fmt" "net/http" ) ... func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { id, err := app.readIDParam(r) if err != nil { http.NotFound(w, r) return } fmt.Fprintf(w, "show the details of movie %d\n", id) }
اطلاعات تکمیلی
Routeهای متداخل
مهم است بدانید که httprouter اجازه routeهای متداخل را نمیدهد؛ routeهایی که ممکن است با یک درخواست یکسان match شوند. بنابراین، برای مثال، نمیتوانید همزمان routeای مثل GET /foo/new و route دیگری با یک بخش پارامتری که با آن تداخل دارد، مثل GET /foo/:id، ثبت کنید.
اگر برای endpointهای API خود از یک ساختار REST استاندارد استفاده میکنید، همانطور که در این کتاب انجام میدهیم، این محدودیت بعید است مشکل زیادی برایتان ایجاد کند.
در واقع، میتوان گفت این یک نکته مثبت است. چون routeهای متداخل مجاز نیستند، قانونهای اولویتبندی routing وجود ندارند که لازم باشد نگرانشان باشید و این موضوع خطر bug و رفتار ناخواسته در برنامه را کاهش میدهد.
اما اگر واقعا لازم دارید routeهای متداخل را پشتیبانی کنید، مثلا شاید برای سازگاری با نسخههای قبلی لازم باشد endpointهای یک API موجود را دقیقا بازتولید کنید، پیشنهاد میکنم به جای آن نگاهی به chi، Gorilla mux یا flow بیندازید. همه اینها routerهای خوبی هستند که routeهای متداخل را مجاز میدانند.
سفارشیسازی رفتار httprouter
پکیج httprouter چند گزینه پیکربندی فراهم میکند که میتوانید با آنها رفتار برنامه خود را بیشتر سفارشی کنید؛ از جمله فعال کردن trailing slash redirects و فعال کردن automatic URL path cleaning.
اطلاعات بیشتر درباره تنظیمات موجود را میتوانید اینجا پیدا کنید.