Новости

02.08.2022

Книга «Go: идиомы и паттерны проектирования»

Go быстро набирает популярность в качестве языка для создания веб-сервисов. Существует множество учебников по синтаксису Go, но знать его недостаточно. Автор Джон Боднер описывает и объясняет паттерны проектирования, используемые опытными разработчиками. В книге собрана наиболее важная информация, необходимая для написания чистого и идиоматического Go-кода. Вы научитесь думать как Go-разработчик, вне зависимости от предыдущего опыта программирования.

Контекст


Серверы должны быть способны обрабатывать метаданные, относящиеся к отдельному запросу. Эти метаданные можно разделить на две основные категории: метаданные, необходимые для корректной обработки запроса, и метаданные, указывающие, когда следует прекратить обработку запроса. Например, иногда HTTP-серверу требуется идентификатор отслеживания для идентификации цепочки запросов, проходящей через некоторый ряд микросервисов. Иногда серверу также требуется установить таймер, прекращающий выполнение запросов к другим микросервисам в том случае, если они выполняются слишком долго. Для сохранения такой информации во многих языках используются локальные переменные потока, которые ассоциируют данные с конкретным потоком выполнения операционной системы. Этот подход не работает в Go, потому что у горутин нет уникальных идентификаторов, с помощью которых можно было бы находить те или иные значения. Что еще важнее, применение локальных переменных потока напоминает магию: значения поступают в одно место и затем вдруг появляются в другом месте.

В Go проблема с метаданными запросов решается с помощью конструкции, называемой контекстом. Посмотрим, как выглядит корректный подход к ее использованию.

Что такое контекст

Контекст — это не какой-то дополнительный элемент языка, а просто экземпляр, который соответствует интерфейсу Context, определенному в пакете context. Как вы помните, идиоматический подход в Go поощряет явную передачу данных в виде параметров функции. То же самое справедливо и в случае контекста, который представляет собой просто еще один параметр функции. Наряду с соглашением о том, что ошибка должна быть последним возвращаемым значением функции, в Go имеется и соглашение о том, что контекст должен передаваться в программе явным образом в виде первого параметра функции. Обычно этому параметру контекста дают имя ctx:

func logic(ctx context.Context, info string) (string, error) {
// здесь производятся определенные действия
return "", nil
}


Помимо интерфейса Context, пакет context также содержит несколько фабричных функций для создания и обертывания контекстов. Когда у вас еще нет контекста, как, например, в точке входа в консольную программу, необходимо создать пустой исходный контекст с помощью функции context.Background. Эта функция возвращает переменную типа context.Context. (Это исключение из общепринятого подхода, согласно которому функция должна возвращать конкретный тип.)

Пустой контекст является отправной точкой; при этом каждое последующее добавление метаданных в контекст осуществляется путем обертывания существующего контекста с помощью одной из фабричных функций пакета context:

ctx := context.Background()
result, err := logic(ctx, "a string")

 

В пакете context есть еще одна функция, создающая пустой экземпляр типа context.Context. Это функция context.TODO, которая предназначена для временного использования на этапе разработки. Если вы еще не знаете, откуда будет поступать контекст и как он будет применяться, используйте функцию context.TODO в качестве временной заглушки. Однако эта функция не должна использоваться в окончательном коде приложения.


При создании HTTP-сервера следует использовать несколько иной паттерн получения и передачи контекста через промежуточные слои до обработчика (экземпляра типа http.Handler), расположенного на верхнем уровне иерархии. К сожалению, контекст был добавлен в API языка Go намного позже создания пакета net/http, и в силу обязательства по обеспечению совместимости в интерфейс http.Handler уже нельзя было добавить параметр context.Context.
Однако обязательство по обеспечению совместимости не запрещает добавление новых методов в существующие типы, что и сделали разработчики языка Go. У типа http.Request есть два метода для работы с контекстом.

— Метод Context возвращает ассоциированный с запросом экземпляр типа context.Context.

— Метод WithContext принимает экземпляр типа context.Context и возвращает новый экземпляр типа http.Request, содержащий состояние старого запроса, дополненное предоставленным экземпляром типа context.Context.

Общий паттерн выглядит следующим образом:

func Middleware(handler http.Handler) http.Handler {
   return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
      ctx := req.Context()
      // обертываем контекст — как это делается, мы увидим чуть позже!
      req = req.WithContext(ctx)
      handler.ServeHTTP(rw, req)
   })
}


В промежуточном слое мы прежде всего извлекаем существующий контекст из запроса с помощью метода Context. После занесения значений в контекст мы создаем новый запрос на основе старого запроса и незаполненного контекста, используя метод WithContext. Наконец, мы вызываем свой обработчик и передаем ему новый запрос и имеющийся экземпляр типа http.ResponseWriter.

Внутри обработчика мы извлекаем контекст из запроса с помощью метода Context и вызываем свою бизнес-логику, передавая ей контекст в качестве первого параметра, как уже делалось ранее:

func handler(rw http.ResponseWriter, req *http.Request) {
   ctx := req.Context()
   err := req.ParseForm()
   if err != nil {
      rw.WriteHeader(http.StatusInternalServerError)
      rw.Write([]byte(err.Error()))
      return
   }
   data := req.FormValue("data")
   result, err := logic(ctx, data)
   if err != nil {
      rw.WriteHeader(http.StatusInternalServerError)
      rw.Write([]byte(err.Error()))
      return
   }
   rw.Write([]byte(result))
}


Метод WithContext используется еще в одном случае: когда нужно вызвать из своего приложения некоторый другой HTTP-сервис. Как и в случае передачи контекста через промежуточные слои, при этом нужно снабдить исходящий запрос контекстом с помощью метода WithContext:

type ServiceCaller struct {
   client *http.Client
}
func (sc ServiceCaller) callAnotherService(ctx context.Context, data string)(string, error) {
   req, err := http.NewRequest(http.MethodGet,
                                        "http://example.com?data="+data, nil)
    if err != nil {
      return "", err
   }
   req = req.WithContext(ctx)
   resp, err := sc.client.Do(req)
   if err != nil {
      return "", err
   }
   defer resp.Body.Close()
       if resp.StatusCode != http.StatusOK {
   return "", fmt.Errorf("Unexpected status code %d",
   resp.StatusCode)
   } 
   // оставшиеся действия по обработке ответа
   id, err := processResponse(resp.Body)
   return id, err
}


Теперь, уже зная, как можно получить и передать контекст, посмотрим, как можно сделать его полезным. Начнем мы с операции отмены.

Отмена

Допустим, что у вас есть запрос, который порождает несколько горутин, каждая из которых вызывает определенный HTTP-сервис. Если один из вызываемых сервисов возвратит ошибку, не позволяющую возвратить корректный результат, в продолжении дальнейшей обработки внутри остальных горутин не будет никакого смысла. В таком случае в Go производится отмена горутин, реализуемая посредством контекста.

Отменяемый контекст можно создать с помощью функции context.WithCancel, которая принимает экземпляр типа context.Context и возвращает экземпляры типов context.Context и context.CancelFunc. При этом возвращаемый экземпляр типа context.Context представляет собой не тот же контекст, который был передан в функцию, а дочерний контекст, который обертывает переданный в функцию родительский экземпляр типа context.Context. Экземпляр типа context.CancelFunc представляет собой функцию, которая отменяет контекст, сообщая о том, что нужно прекратить обработку, всему тому коду, который осуществляет прослушивание на предмет возможной отмены.

Мы еще не раз увидим этот паттерн обертывания. Контекст при этом рассматривается как неизменяемый экземпляр. Каждое последующее добавление информации в контекст осуществляется путем обертывания имеющегося родительского контекста в дочерний контекст. Это позволяет нам использовать контексты для передачи информации в более глубокие слои кода. Контекст никогда не применяется для передачи информации из более глубоких в более высокие слои.



Посмотрим, как это выглядит на практике. Поскольку этот код производит настройку сервера, его нельзя запустить в онлайн-песочнице, однако вы можете скачать его по адресу oreil.ly/qQy5c. Сначала произведем настройку двух серверов в файле с именем servers.go:

func slowServer() *httptest.Server {
   s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
      r *http.Request) {
      time.Sleep(2 * time.Second)
      w.Write([]byte("Slow response"))
   }))
   return s
}
func fastServer() *httptest.Server {
  s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
      r *http.Request) {
      if r.URL.Query().Get("error") == "true" {
         w.Write([]byte("error"))
         return
      }
      w.Write([]byte("ok"))
   }))
   return s
}


При вызове этих функций происходит запуск серверов. Первый сервер в течение двух секунд находится в режиме ожидания, после чего возвращает сообщение Slow response (Медленная реакция). Второй сервер выполняет проверку в отношении того, не равен ли параметр запроса типа error значению true. Если это так, он возвращает сообщение error (ошибка). В противном случае он возвращает сообщение ok.

Теперь мы напишем клиентскую часть кода, разместив ее в файле с именем client.go:

var client = http.Client{}
func callBoth(ctx context.Context, errVal string, slowURL string, fastURL string) {
   ctx, cancel := context.WithCancel(ctx)
   defer cancel()
   var wg sync.WaitGroup
   wg.Add(2)
   go func() {
      defer wg.Done()
      err := callServer(ctx, "slow", slowURL)
      if err != nil {
         cancel()
      }
   }()
   go func() {
      defer wg.Done()
      err := callServer(ctx, "fast", fastURL+"?error="+errVal)
      if err != nil {
         cancel()
      }
   }()
   wg.Wait()
   fmt.Println("done with both")
}
func callServer(ctx context.Context, label string, url string) error {
   req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   if err != nil {
      fmt.Println(label, "request err:", err)
      return err
   }
   resp, err := client.Do(req)
   if err != nil {
      fmt.Println(label, "response err:", err)
      return err
   }
   data, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      fmt.Println(label, "read err:", err)
      return err
   }
   result := string(data)
   if result != "" {
      fmt.Println(label, "result:", result)
   }
   if result == "error" {
      fmt.Println("cancelling from", label)
      return errors.New("error happened")
   }
   return nil
}


В этом файле происходит самое интересное. Сначала функция callBoth создает на основе переданного ей контекста отменяемый контекст и функцию отмены. В соответствии с общепринятым соглашением мы дали этой функциональной переменной имя cancel. Важно помнить, что каждый раз, когда вы создаете отменяемый контекст, вы должны вызывать функцию отмены. При этом не произойдет ничего страшного, если она будет вызвана несколько раз: после первого вызова все следующие игнорируются. Чтобы эта функция была гарантированно вызвана в любом случае, мы вызываем ее в операторе defer. Затем мы запускаем две горутины, передаем отменяемый контекст, метку и URL-адрес в функцию callServer и ждем, пока обе функции завершат свое выполнение. Если один или оба вызова функции callServer возвращают ошибку, мы вызываем функцию cancel.

Функция callServer представляет собой простой клиент. Здесь мы создаем свои запросы с отменяемым контекстом и выполняем вызов. В случае возникновения ошибки или возвращения строки error мы возвращаем ошибку.

Вот как будет выглядеть расположенная в файле main.go основная функция, которая будет запускать нашу программу:

func main() {
   ss := slowServer()
   defer ss.Close()
   fs := fastServer()
   defer fs.Close()
   ctx := context.Background()
   callBoth(ctx, os.Args[1], ss.URL, fs.URL)
}


В функции main мы запускаем серверы и создаем контекст, после чего вызываем клиенты, передавая им контекст, первый аргумент программы и URL-адреса серверов.

При успешном выполнении мы увидим следующий результат:

$ make run-ok
go build
./context_cancel false
fast result: ok
slow result: Slow response
done with both


В случае возникновения ошибки результат будет выглядеть следующим образом:

$ make run-cancel
go build
./context_cancel true
fast result: error
cancelling from fast
slow response err: Get "http://127.0.0.1:38804": context canceled
done with both

 

Каждый раз, когда вы создаете контекст с привязанной к нему функцией отмены, вы должны вызвать эту функцию отмены после выполнения обработки и в случае успешного выполнения, и при возникновении ошибки. В противном случае в вашей программе будет происходить утечка ресурсов (памяти и горутин), что в конечном итоге приведет к замедлению или прекращению работы программы. Ошибки не будет, если вы вызовете функцию отмены несколько раз, поскольку все последующие вызовы после первого не будут производить никаких действий. Самый простой способ обеспечить гарантированный вызов функции отмены сводится к тому, чтобы вызывать ее с помощью оператора defer сразу после ее возвращения другой функцией.


Хотя в некоторых случаях и удобно использовать ручную отмену, это не единственный возможный подход. В следующем разделе будет показано, как можно производить автоматическую отмену с использованием тайм-аутов.

Таймеры

Одной из самых важных функций сервера является управление запросами. Начинающие программисты часто думают, что сервер должен принимать максимально возможное количество запросов и работать над ними в течение максимально возможного времени до тех пор, пока не сможет возвратить результат каждому клиенту.

Проблемой этого подхода является то, что он не поддается масштабированию. Сервер является совместно используемым ресурсом, и, как в случае любого совместно используемого ресурса, каждый пользователь хочет использовать его в максимально возможной степени, не сильно беспокоясь о том, что в нем могут нуждаться и другие пользователи. Совместно используемый ресурс должен сам обеспечить справедливое распределение времени между всеми имеющимися пользователями.

Для управления своей нагрузкой сервер обычно может сделать следующее:

— ограничить количество одновременных запросов;

— ограничить количество запросов в очереди на выполнение;

— ограничить время выполнения запроса;
— ограничить количество используемых запросом ресурсов (таких как память или дисковое пространство).

Go предоставляет инструменты для наложения первых трех ограничений. Мы уже видели, как можно наложить первые два ограничения, при обсуждении конкурентности в главе 10. Сервер может управлять объемом одновременной нагрузки, ограничивая количество запускаемых горутин. Управлять размером очереди на выполнение можно посредством буферизованных каналов.

Контекст позволяет вам управлять временем выполнения запроса. При создании приложения вы должны иметь представление о минимально необходимой производительности, то есть о том, насколько быстро должен выполняться запрос, чтобы пользователь оставался удовлетворенным работой приложения. Если вы знаете, каким должно быть максимальное время выполнения запроса, вы можете наложить это ограничение с помощью контекста.

Если вы хотите ограничить объем используемой запросом памяти или дискового пространства, вам придется написать собственный код для управления этим ресурсом. Обсуждение этой темы выходит за рамки этой книги.


Для создания контекста с ограничением по времени можно использовать одну из двух функций. Первой функцией является функция context.WithTimeout, которая принимает два параметра: существующий контекст и экземпляр типа time.Duration, представляющий промежуток времени, после которого будет производиться автоматическая отмена контекста. Возвращает эта функция контекст, автоматически запускающий отмену после указанного промежутка времени, и функцию отмены, вызываемую для немедленной отмены контекста.

Второй функцией является функция context.WithDeadline, которая принимает существующий контекст и экземпляр типа time.Time, указывающий, в какой момент времени будет производиться автоматическая отмена контекста. Подобно функции context.WithTimeout, она возвращает контекст, автоматически запускающий отмену в указанный момент времени, и функцию отмены.

Если вы передадите функции context.WithDeadline уже прошедший момент времени, то она создаст уже отмененный контекст.


Узнать, когда будет произведена автоматическая отмена контекста, можно, вызвав метод Deadline в экземпляре типа context.Context. Этот метод возвращает экземпляр типа time.Time, представляющий этот момент времени, и значение типа bool, указывающее, был ли установлен тайм-аут. Это напоминает использование идиомы «запятая-ok» для чтения карт или каналов.

При наложении ограничения на общую длительность выполнения запроса иногда нужно дополнительно разбить это время на несколько промежутков. Если ваш сервис вызывает некоторый другой сервис, часто требуется ограничить время выполнения сетевого вызова, зарезервировав время для остальной обработки или для других сетевых вызовов. В таком случае для управления длительностью выполнения отдельного вызова следует создать дочерний контекст, обертывающий родительский контекст, с помощью функции context.WithTimeout или context.WithDeadline.

При этом любой тайм-аут, установленный в дочернем контексте, будет ограничен тайм-аутом, установленным в родительском контексте. Таким образом, если длительность тайм-аута будет составлять две секунды в родительском контексте и три секунды в дочернем контексте, то по истечении двух секунд будет произведена отмена и родительского, и дочернего контекста.
В качестве примера рассмотрим следующую простую программу:

ctx := context.Background()
parent, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 3*time.Second)
defer cancel2()
start := time.Now()
<-child.Done()
end := time.Now()
fmt.Println(end.Sub(start))


В этом примере кода мы устанавливаем двухсекундный тайм-аут в родительском контексте и трехсекундный тайм-аут в дочернем контексте. Затем мы ждем отмены дочернего контекста, дожидаясь момента возвращения значения каналом, возвращенным методом Done, вызванным в дочернем экземпляре типа context.Context. Подробнее о методе Done мы поговорим в следующем разделе.

Выполнив эту программу в онлайн-песочнице (https://oreil.ly/FS8h2), вы получите следующий результат:

2s

Комментарии: 0

Пока нет комментариев


Оставить комментарий






CAPTCHAОбновить изображение

Наберите текст, изображённый на картинке

Все поля обязательны к заполнению.

Перед публикацией комментарии проходят модерацию.