1. Müqavilə və cavab formatı
List endpoint yazanda elə görünür ki, orada “sadəcə massiv qaytarmaq” kifayətdir. Amma məhz list-cavablar ən çox öz həyatlarını yaşamağa başlayır: onları skriptlər parse edir, onların əsasında avtotestlər yazılır, onları JSON kimi “bərabərlik” üzrə müqayisə edirlər, istifadəçilər isə elementlər yerlərini “dəyişəndə” şikayət edirlər. Buna görə list müqaviləsi təkcə id/title/done sahələri deyil, həm də sıra və filtr davranışı qaydalarıdır.
GET /api/v1/tasks üçün aşağıdakı müqaviləni sabitləşdiririk:
- Metod və yol: GET /api/v1/tasks
- Query parametrləri:
- done=true|false — status üzrə opsional filtr
- sort=id|title — opsional sıralama
- Cavab: 200 OK və aşağıdakı kimi JSON obyekt { "tasks": [...] }, “çılpaq massiv” yox, ki, formatı cavab tipini dəyişmədən genişləndirmək mümkün olsun (məsələn, "total" əlavə etmək).
Niyə ayrıca “stabilik” haqqında danışırıq? Çünki hətta belə, guya “sadə” olan sıralama kimi şeylər də reallaşdırmanın daxilində dəyişiklik olanda bərabər elementlər üçün fərqli sıra verə bilər. Testlərin sıraya bağlanıb qəfil sınmasının səbəblərindən biri də budur.
Müştəriləri arxeoloqa çevirməmək üçün çıxışı əvvəlcədən deterministik edirik.
DTO və cavab örtüyü
Filtrləmədən və sıralamadan əvvəl məlumatın hansı formada çölə çıxacağı barədə razılaşmaq vacibdir. HTTP qatında adətən DTO-larla (data transfer objects) işləyirik — bunlar API müqaviləsini əks etdirən və domen modeli ilə 100% üst-üstə düşməyə məcbur olmayan strukturlardır. Bu normaldır: domen — qaydalar haqqındadır, HTTP — qablaşdırma haqqındadır.
Minimal DTO və cavab-örtüyü:
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type ListTasksResponse struct {
Tasks []TaskDTO `json:"tasks"`
}
Niyə { "tasks": [...] }, sadəcə []TaskDTO yox? Çünki API nadir hallarda “sadəcə siyahı” olaraq qalır. Sonradan total, next_page, generated_at əlavə etmək istəyəndə — müştərilərə “massiv → obyekt” parserini yenidən yazdıran ağrılı format dəyişikliyi olmayacaq.
Və bəli, bu, əlavə 10 bayt JSON-un sakit həyat üçün dəyərli investisiya olduğu hallardandır.
2. Query params: oxuma və emal
Query-parametrlər zərərsiz görünür, çünki onlar “sadəcə sətirlərdir”. Amma əslində bu, JSON body və ya yoldakı {id} qədər istifadəçi girişidir: orada boş dəyərlər, dublikatlar, gözlənilməz variantlar olur və bunların hamısını stabil şəkildə emal etmək lazımdır. Əgər “hər şeyi olduğu kimi qəbul etsək”, bug dərhal yox, müştəri təsadüfən ?done=tru göndərəndə ortaya çıxacaq.
Praktikada r.URL.Query() url.Values qaytarır, yəni map[string][]string.
import "net/http"
func readQuery(r *http.Request) map[string][]string {
q := r.URL.Query()
return q
}
Və burada ilk incəlik üzə çıxır: bir açar bir neçə dəfə ötürülə bilər, məsələn ?sort=id&sort=title. Tədris üçün sərt siyasət seçəcəyik: parametr bir dəfədən çox göstərilibsə — bu, validasiyadır (400), çünki əks halda müştəri nəticəni proqnozlaşdırmaqda çətinlik çəkir.
Parametr üçün dəqiq bir dəyər
“Dəqiq bir dəyər götür” adlı kiçik helper yazaq:
import (
"fmt"
"net/url"
)
func getSingleQuery(q url.Values, key string) (string, bool, error) {
vals, ok := q[key]
if !ok {
return "", false, nil
}
if len(vals) != 1 {
return "", true, fmt.Errorf("query %q must be single", key)
}
return vals[0], true, nil
}
Burada present (ikinci nəticə) göründüyündən daha vacibdir. O, “parametr yoxdur” vəziyyətini “parametr var, amma boş/korlanmışdır” vəziyyətindən ayırmağa kömək edir. done üçün bu həlledicidir.
done filtri: bir bool əvəzinə üç vəziyyət
Filtrlərdə yeni başlayanların tipik tələsi var: done-u bool kimi oxuyub məsələni bitmiş saymaq istəyirlər. Amma query-də done-un əslində üç vəziyyəti var və bunlar semantik olaraq fərqlidir:
- parametr yoxdur → filtr tətbiq etmirik
- parametr var və validdir (true/false) → filtri tətbiq edirik
- parametr var, amma etibarsızdır → bu 400-dür (validation)
Əgər (1) və (2)-ni fərqləndirməsəniz, bug yaranacaq: “parametr yoxdur” done=false ilə eyni davranacaq və müştəri qəfil yalnız tamamlanmamış tapşırıqları görəcək.
Opsional bool üçün parser yazaq:
import (
"net/url"
"strconv"
)
func parseOptionalBool(q url.Values, key string) (value bool, present bool, err error) {
raw, present, err := getSingleQuery(q, key)
if err != nil || !present {
return false, present, err
}
if raw == "" {
return false, true, strconv.ErrSyntax
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false, true, err
}
return v, true, nil
}
İndi filtrasiya. Biz bunu mümkün qədər sadə və əlavə ayrılmalar olmadan etmək istəyirik. “In-place filtrasiya” nümunəsi belə görünür: dst := tasks[:0] götürürük və uyğun elementləri əlavə edirik. Bu sehr kimi səslənir, amma əslində bu, sadəcə slice altında olan massivin səliqəli şəkildə yenidən istifadəsidir.
func filterByDone(tasks []TaskDTO, done bool) []TaskDTO {
dst := tasks[:0]
for _, t := range tasks {
if t.Done == done {
dst = append(dst, t)
}
}
return dst
}
Əgər indi “sanki qədim gopherlərin ritualıdır” deyə düşündünüzsə — normaldır. Öyrəşəcəksiniz: Go-da belə şeylər oxunaqlı sayılır, çünki onlar qısa və proqnozlaşdırılandır.
Query validasiyası və səhvlərin ValidationError-ə çevrilməsi
Handler daxilində “burada alınmadısa, 400-ü belə yaz, orada isə başqa cür” kimi qaydanı yaymaq istəmirik. Artıq razılaşmışıq ki, səhvlər üçün vahid yolumuz writeError-dir, deməli handler domen baxımından başa düşülən səhvlər qaytarmalıdır: ValidationError 400 üçün və ErrNotFound 404 üçün (list-də buna daha az ehtiyac olur).
Bu mühazirədə belə hesab edəcəyik ki, ValidationError tipi və writeError/mapError artıq mövcuddur (siz onları vahid səhv müqaviləsi haqqında mühazirədə yazmışdınız). Onda query validasiyası sadə qaydaya çevrilir: hər bir etibarsız hal — &ValidationError{Fields: ...}.
Məsələn, əgər done xarabdırsa:
func invalidDoneQuery() error {
return &ValidationError{
Fields: map[string]string{
"done": "must be true or false",
},
}
}
Əgər sort naməlumdursa:
func invalidSortQuery() error {
return &ValidationError{
Fields: map[string]string{
"sort": "supported: id, title",
},
}
}
Bu, müştəri üçün rahatdır: o, həmişə bir format alır və xətanı konkret parametrin yanında işarələyə bilər. Sizə isə rahatdır, çünki handler xətti və oxunaqlı qalır.
3. Sıralama və çıxışın stabilliyi
List-endpoint-də sıralama yalnız “gözəllik” üçün lazım deyil. O, deterministiklik üçündür: müştəriyə (və testlərinizə) eyni sorğunun həmişə elementləri eyni sırada qaytarması daha rahatdır.
Əsas incəlik: əgər siz eyni ola bilən sahə üzrə sıralayırsınızsa (məsələn, title) və ikinci qayda (tie-break) təyin etmirsinizsə, onda eyni title-a malik elementlərin sırası “gəzə” bilər. Hətta kod eyni olsa da, bərabər elementlərin sırası mühitlər və alət versiyaları arasında dəyişə bilər.
Biz qaydaları belə müəyyən edirik:
- sort yoxdur və ya sort=id → ID üzrə artan sıralama
- sort=title → Title üzrə sıralama, eyni Title olduqda isə ID üzrə
Kod:
import (
"fmt"
"sort"
)
func sortTasks(tasks []TaskDTO, sortBy string) error {
switch sortBy {
case "", "id":
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return nil
case "title":
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].Title == tasks[j].Title {
return tasks[i].ID < tasks[j].ID
}
return tasks[i].Title < tasks[j].Title
})
return nil
default:
return fmt.Errorf("unsupported sort: %s", sortBy)
}
}
ID üzrə tie-break nəticəni hətta adlar üst-üstə düşəndə də deterministik edir. Bu, xüsusilə sonra JSON-u “sətir kimi” müqayisə edən testlər və ya golden files əlavə etdikdə vacibdir.
4. Handler: pipeline parse → list → filter → sort → encode
List handler-in gözəlliyi ondadır ki, o, server ardıcıllığı “əzələsini” ideal şəkildə məşq etdirir. Kodun faylda sıçramadan yuxarıdan aşağı oxunmasını istəyirik.
Handler sxemi:
flowchart TD
A[GET /api/v1/tasks] --> B[parse query: done/sort]
B --> C["app.List -> []TaskDTO"]
C --> D[optional filter done]
D --> E[sort + tie-break]
E --> F["encode JSON {tasks:...}"]
İndi handler-in karkası. Servis qatını hələlik minimal edək:
import "context"
type TaskLister interface {
List(ctx context.Context) ([]TaskDTO, error)
}
Və handler özü:
import "net/http"
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
done, hasDone, err := parseOptionalBool(q, "done")
if err != nil {
writeError(w, &ValidationError{Fields: map[string]string{"done": "must be true or false"}})
return
}
sortBy, _, err := getSingleQuery(q, "sort")
if err != nil {
writeError(w, &ValidationError{Fields: map[string]string{"sort": "must be single"}})
return
}
tasks, err := s.app.List(r.Context())
if err != nil {
writeError(w, err)
return
}
if hasDone {
tasks = filterByDone(tasks, done)
}
if err := sortTasks(tasks, sortBy); err != nil {
writeError(w, &ValidationError{Fields: map[string]string{"sort": "supported: id, title"}})
return
}
writeJSON(w, http.StatusOK, ListTasksResponse{Tasks: tasks})
}
Aşağıdakı bir neçə “xırda, amma xırda olmayan” məqama diqqət edin:
- Biz həmişə sıralayırıq (hətta sort boş olsa belə): bu, default stabilikdir.
- Biz məlumatları əldə etdikdən sonra filtrləyirik: beləliklə parse/query məntiqi app qatına girmir. Bu, yeganə mümkün dizayn deyil, amma tədris üçün uyğundur: HTTP qatı giriş/çıxış müqaviləsinə cavab verir.
- Query səhvləri ValidationError-ə çevrilir, qalan hər şey isə app qatının qaytardığı kimi qalır (və sonra writeError 400/404/500-ü həll edəcək).
Əgər artıq writeJSON helperiniz varsa, o adətən belə görünür:
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
Sorğu nümunələri
Server kodu hekayənin yarısıdır. Digər yarısı — ondan real istifadənin necə olmasıdır. API-yə müştərinin gözü ilə baxanda dərhal görünür: müqavilə harada “deşikli”, harada isə proqnozlaşdırılanıdır.
Nümunə 1: bütün tapşırıqları almaq, standart sıralama ilə (id üzrə)
curl -i "http://localhost:8080/api/v1/tasks"
Gözlənilən (nümunə):
{
"tasks": [
{ "id": 1, "title": "Buy milk", "done": false },
{ "id": 2, "title": "Write Go code", "done": true }
]
}
Nümunə 2: yalnız tamamlanmışlar, ad üzrə sıralama
curl -i "http://localhost:8080/api/v1/tasks?done=true&sort=title"
Nümunə 3: query-də xəta
curl -i "http://localhost:8080/api/v1/tasks?done=tru"
Və burada xüsusilə vacibdir ki, müştəri təsadüfi mətn yox, sizin vahid error envelope-unuzu alsın, məsələn:
{
"error": {
"code": "validation",
"message": "invalid request",
"fields": {
"done": "must be true or false"
}
}
}
Müştəri başqa dildə yazılmış olsa belə, bu formatı elementar şəkildə emal etmək olar: code sinfi müəyyən edir, fields konkret yeri işarələməyə imkan verir, message isə insana göstərilir.
5. Tipik səhvlər
Səhv №1: “əgər sıralamasan, onsuz da ardıcıllıqla olacaq” düşünmək.
Saxlama slice-də olduqca və tapşırıqlar yalnız sona əlavə olunduqca elə görünür ki, sıra “öz-özünə stabil”dir. Amma içəridə map, fayldan oxuma, bir neçə mənbədən birləşdirmə və ya sadəcə reallaşdırma dəyişikliyi yaranan kimi sıra proqnozlaşdırılmaz olur. Düzgün vərdiş: list endpoint özü sıraya görə cavabdehdir və hər dəfə çıxışdan əvvəl sıralayır.
Səhv №2: done-un üç vəziyyəti olduğunu unutmaq və təsadüfən defolt filtr etmək.
Əgər done, _ := strconv.ParseBool(q.Get("done")) edirsinizsə, parametrin olmaması false-ə çevrilir və siz qəfil yalnız tamamlanmamış tapşırıqları qaytarırsınız. Müştəri “server datanı itirir” deyə düşünəcək. Bunun yeganə çarəsi: (value, present, err) qaytarmaq və filtri yalnız present olduqda tətbiq etməkdir.
Səhv №3: title üzrə tie-break olmadan sıralamaq və “sıçrayan” sıraya təəccüblənmək.
İki title eyni olanda, ikinci kriteriyası olmayan sıralama sizə elementlərin “əvvəlki kimi” qalacağına zəmanət vermir. Alət versiyaları arasında və ya başqa data dəsti ilə bərabər elementlərin sırası dəyişə bilər. Testlərin xəyalən qırılmaması üçün tie-break əlavə edin (məsələn, id).
Səhv №4: ?sort=id&sort=title qəbul etmək və “ilk dəyəri səssizcə götürmək”.
Belə API debugging üçün narahat olur: müştəri səhv etdi, server isə səssizcə “nəsə” etdi. Stabil müqavilələrdə ya “ilkini götürürük” qaydasını dəqiq sənədləşdirmək, ya da (tədris və etibarlılıq üçün daha sadə olan) bir parametr tələb edib 400 qaytarmaq daha yaxşıdır.
Səhv №5: çılpaq massiv qaytarıb sonra “genişlətməyə yerin olmaması”.
Bu gün sizə elə gəlir ki, tapşırıqlar siyahısı sadəcə []TaskDTO-dur. Sabah total, o biri gün zaman damğası və ya versiya lazım olacaq. Əgər cavab artıq obyekt {"tasks": [...]}-dirsə, sahəni ağrısız əlavə edəcəksiniz. Əgər cavab massivdirsə, müştərilərin parserini sındırmaq lazım gələcək. Bu, JSON-da kiçik “artıqlığın” həqiqətən həftələrlə vaxt qənaət etdiyi nadir hallardandır.
GO TO FULL VERSION