Русский
Русский
English
Статистика
Реклама

Go

Перевод Разработка REST-серверов на Go. Часть 1 стандартная библиотека

28.05.2021 16:10:14 | Автор: admin
Это первый материал из серии статей, посвящённой разработке REST-серверов на Go. В этих статьях я планирую описать реализацию простого REST-сервера с использованием нескольких различных подходов. В результате эти подходы можно будет сравнить друг с другом, можно будет понять их относительные преимущества друг перед другом.

Первый вопрос разработчиков, которые только начинают применять Go, часто выглядит так: Какой фреймворк стоит использовать для решения задачи X. И хотя это совершенно нормальный вопрос, если задавать его, имея в виду веб-приложения и серверы, написанные на многих других языках, в случае с Go при ответе на этот вопрос нужно принять во внимание множество тонкостей. Существуют серьёзные аргументы как за, так и против использования фреймворков в Go-проектах. Я, работая над статьями из этой серии, вижу своей целью объективное разностороннее исследование этого вопроса.

Задача


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

В нашем случае сервер представляет собой простую бэкенд-систему для приложения, реализующего функционал управления задачами (вроде Google Keep, Todoist и прочих подобных). Сервер предоставляет клиентам следующий REST API:

POST  /task/       : создаёт задачу и возвращает её IDGET  /task/<taskid>   : возвращает одну задачу по её IDGET  /task/       : возвращает все задачиDELETE /task/<taskid>   : удаляет задачу по IDGET  /tag/<tagname>   : возвращает список задач с заданным тегомGET  /due/<yy>/<mm>/<dd> : возвращает список задач, запланированных на указанную дату

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

Наш сервер поддерживает GET-, POST- и DELETE-запросы, некоторые из них с возможностью использования нескольких путей. То, что в описании API приведено в угловых скобках (<...>), обозначает параметры, которые клиент предоставляет серверу в виде части запроса. Например, запрос GET /task/42 направлен на получение с сервера задачи с ID 42. ID это уникальные идентификаторы задач.

Для кодирования данных используется формат JSON. При выполнении запроса POST /task/ клиент отправляет серверу JSON-представление задачи, которую нужно создать. И, аналогично, в ответах на те запросы, в описании которых сказано, что они что-то возвращают, содержатся JSON-данные. В частности, они размещаются в теле HTTP-ответов.

Код


Далее мы будем заниматься поэтапным написанием кода сервера на Go. Его полный вариант можно найти здесь. Это самодостаточный Go-модуль, в котором не используются зависимости. После клонирования или копирования директории проекта на компьютер сервер можно тут же, ничего дополнительно не устанавливая, запустить:

$ SERVERPORT=4112 go run .

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

Модель


Начнём с обсуждения модели (или уровня данных) для нашего сервера. Найти её можно в пакете taskstore (internal/taskstore в директории проекта). Это простая абстракция, представляющая базу данных, в которой хранятся задачи. Вот её API:

func New() *TaskStore// CreateTask создаёт новую задачу в хранилище.func (ts *TaskStore) CreateTask(text string, tags []string, due time.Time) int// GetTask получает задачу из хранилища по ID. Если ID не существует -// будет возвращена ошибка.func (ts *TaskStore) GetTask(id int) (Task, error)// DeleteTask удаляет задачу с заданным ID. Если ID не существует -// будет возвращена ошибка.func (ts *TaskStore) DeleteTask(id int) error// DeleteAllTasks удаляет из хранилища все задачи.func (ts *TaskStore) DeleteAllTasks() error// GetAllTasks возвращает из хранилища все задачи в произвольном порядке.func (ts *TaskStore) GetAllTasks() []Task// GetTasksByTag возвращает, в произвольном порядке, все задачи// с заданным тегом.func (ts *TaskStore) GetTasksByTag(tag string) []Task// GetTasksByDueDate возвращает, в произвольном порядке, все задачи, которые// запланированы на указанную дату.func (ts *TaskStore) GetTasksByDueDate(year int, month time.Month, day int) []Task

Вот объявление типа Task:

type Task struct {Id  int    `json:"id"`Text string  `json:"text"`Tags []string `json:"tags"`Due time.Time `json:"due"`}

В пакете taskstore этот API реализован с использованием простого словаря map[int]Task, данные при этом хранятся в памяти. Но несложно представить себе реализацию этого API, основанную на базе данных. В реальном приложении TaskStore, вероятнее всего, будет интерфейсом, реализовать который могут разные бэкенды. Но для нашего простого примера достаточно и такого API. Если вы хотите поупражняться реализуйте TaskStore с использованием чего-то вроде MongoDB.

Подготовка сервера к работе


Функция main нашего сервера устроена довольно просто:

func main() {mux := http.NewServeMux()server := NewTaskServer()mux.HandleFunc("/task/", server.taskHandler)mux.HandleFunc("/tag/", server.tagHandler)mux.HandleFunc("/due/", server.dueHandler)log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))}

Уделим немного времени команде NewTaskServer, а потом поговорим о маршрутизаторе и об обработчиках путей.

NewTaskServer это конструктор для нашего сервера, имеющего тип taskServer. Сервер включает в себя TaskStore, что безопасно с точки зрения конкурентного доступа к данным.

type taskServer struct {store *taskstore.TaskStore}func NewTaskServer() *taskServer {store := taskstore.New()return &taskServer{store: store}}

Маршрутизация и обработчики путей


Теперь вернёмся к маршрутизации. Тут используются стандартный HTTP-мультиплексор, входящий в состав пакета net/http:

mux.HandleFunc("/task/", server.taskHandler)mux.HandleFunc("/tag/", server.tagHandler)mux.HandleFunc("/due/", server.dueHandler)

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

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

Изучим обработчик путей taskHandler:

func (ts *taskServer) taskHandler(w http.ResponseWriter, req *http.Request) {if req.URL.Path == "/task/" {// Запрос направлен к "/task/", без идущего в конце ID.if req.Method == http.MethodPost {ts.createTaskHandler(w, req)} else if req.Method == http.MethodGet {ts.getAllTasksHandler(w, req)} else if req.Method == http.MethodDelete {ts.deleteAllTasksHandler(w, req)} else {http.Error(w, fmt.Sprintf("expect method GET, DELETE or POST at /task/, got %v", req.Method), http.StatusMethodNotAllowed)return}

Мы начинаем работу с проверки на точное совпадение пути с /task/ (это означает, что в конце нет <taskid>). Тут нам нужно понять то, какой HTTP-метод используется, и вызвать соответствующий метод сервера. Большинство обработчиков путей это достаточно простые обёртки для API TaskStore. Посмотрим на один из таких обработчиков:

func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {log.Printf("handling get all tasks at %s\n", req.URL.Path)allTasks := ts.store.GetAllTasks()js, err := json.Marshal(allTasks)if err != nil {http.Error(w, err.Error(), http.StatusInternalServerError)return}w.Header().Set("Content-Type", "application/json")w.Write(js)}

Он решает две основные задачи:

  1. Получает данные из модели (TaskStore).
  2. Формирует HTTP-ответ для клиента.

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

Вернёмся теперь к taskHandler. Пока мы видели только то, как он обрабатывает запросы, в которых имеется точное совпадение с путём /task/. А как насчёт пути /task/<taskid>? Именно тут в дело вступает вторая часть функции:

} else {// В запросе есть ID, выглядит он как "/task/<id>".path := strings.Trim(req.URL.Path, "/")pathParts := strings.Split(path, "/")if len(pathParts) < 2 {http.Error(w, "expect /task/<id> in task handler", http.StatusBadRequest)return}id, err := strconv.Atoi(pathParts[1])if err != nil {http.Error(w, err.Error(), http.StatusBadRequest)return}if req.Method == http.MethodDelete {ts.deleteTaskHandler(w, req, int(id))} else if req.Method == http.MethodGet {ts.getTaskHandler(w, req, int(id))} else {http.Error(w, fmt.Sprintf("expect method GET or DELETE at /task/<id>, got %v", req.Method), http.StatusMethodNotAllowed)return}}

Когда запрос не в точности соответствует пути /task/, мы ожидаем, что за косой чертой будет идти числовой ID задачи. Вышеприведённый код анализирует этот ID и вызывает соответствующий обработчик (основываясь на методе HTTP-запроса).

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

Улучшение сервера


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

Одной из используемых нами программных конструкций, которая, очевидно, нуждается в улучшении, и о которой мы уже говорили, является повторяющийся код подготовки JSON-данных при формировании HTTP-ответов. Я создал отдельную версию сервера, stdlib-factorjson, в которой эта проблема решена. Я выделил эту реализацию сервера в отдельную папку для того чтобы её было легче сравнить с первоначальным кодом сервера и проанализировать изменения. Главное новшество этого кода представлено следующей функцией:

// renderJSON преобразует 'v' в формат JSON и записывает результат, в виде ответа, в w.func renderJSON(w http.ResponseWriter, v interface{}) {js, err := json.Marshal(v)if err != nil {http.Error(w, err.Error(), http.StatusInternalServerError)return}w.Header().Set("Content-Type", "application/json")w.Write(js)}

Используя эту функцию, мы можем переписать код всех обработчиков путей, укоротив его. Вот, например, как теперь выглядит код getAllTasksHandler:

func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {log.Printf("handling get all tasks at %s\n", req.URL.Path)allTasks := ts.store.GetAllTasks()renderJSON(w, allTasks)}

Более фундаментальное улучшение заключалось бы в том, чтобы сделать код сопоставления запросов и путей чище, и в том, чтобы, по возможности, собрать этот код в одном месте. Хотя текущий подход по сопоставлению запросов и путей упрощает отладку, соответствующий код трудно понять с первого взгляда, так как он разбросан по нескольким функциям. Например, предположим, что мы пытаемся разобраться с тем, как обрабатывается запрос DELETE, который направлен к /task/<taskid>. Для этого выполняются следующие действия:

  1. Во-первых мы находим в main мультиплексор и узнаём, что корневой путь /task/ обрабатывается в taskHandler.
  2. Далее, в taskHandler, нам надо найти выражение else, которое отвечает за обработку путей, не точно совпадающих с /task/. Там нам надо прочитать код преобразования <taskid> в целое число.
  3. И наконец мы смотрим на выражение if, в котором перечислены различные методы, применяемые при обработке запросов, соответствующих этому пути, и выясняем, что метод DELETE обрабатывается в deleteTaskHandler.

Можно поместить весь этот код в одно место. Так работать с ним будет гораздо проще и удобнее. Именно на решение этой задачи направлены HTTP-маршрутизаторы сторонних разработчиков. О них мы поговорим во второй части этой серии статей.

Это первая часть из серии статей, посвящённой разработке серверов на Go. Посмотреть список статей можно в начале оригинала этого материала.


Подробнее..

Перевод Разработка REST-серверов на Go. Часть 2 применение маршрутизатора gorillamux

06.06.2021 14:21:06 | Автор: admin
Перед вами второй материал из серии статей, посвящённой разработке REST-серверов на Go. В первом материале этой серии мы создали простой сервер, пользуясь стандартными средствами Go, а после этого отрефакторили код формирования JSON-данных, вынеся его во вспомогательную функцию. Это позволило нам выйти на достаточно компактный код обработчиков маршрутов.

Там мы говорили об одной проблеме нашего сервера, которая заключается в том, что логика маршрутизации разбросана по нескольким местам нашей программы.



Это проблема, с которой сталкиваются все, кто пишет HTTP-сервера, не используя зависимости. Если только сервер, принимая во внимание систему его маршрутов, не является до крайности минималистичной конструкцией (например это некоторые специализированные серверы, имеющие лишь один-два маршрута), то оказывается, что размеры и сложность организации кода маршрутизатора это нечто такое, на что очень быстро обращают внимание опытные программисты.

Улучшенная система маршрутизации


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

Перед переходом к практическому примеру вспомним о том, как устроен API нашего сервера:

POST  /task/       : создаёт задачу и возвращает её IDGET  /task/<taskid>   : возвращает одну задачу по её IDGET  /task/       : возвращает все задачиDELETE /task/<taskid>   : удаляет задачу по IDGET  /tag/<tagname>   : возвращает список задач с заданным тегомGET  /due/<yy>/<mm>/<dd> : возвращает список задач, запланированных на указанную дату

Для того чтобы сделать систему маршрутизации удобнее, мы можем поступить так:

  1. Можно создать механизм, позволяющий задавать отдельные обработчики для разных методов одного и того же маршрута. Например запрос POST /task/ должен обрабатываться одним обработчиком, а запрос GET /task/ другим.
  2. Можно сделать так, чтобы обработчик маршрута выбирался бы на основе более глубокого, чем сейчас, анализа запросов. То есть, например, у нас при таком подходе должна быть возможность указать, что один обработчик обрабатывает запрос к /task/, а другой обработчик обрабатывает запрос к /task/<taskid> с числовым ID.
  3. При этом система обработки маршрутов должна просто извлекать числовой ID из /task/<taskid> и передавать его обработчику каким-нибудь удобным для нас способом.

Написание собственного маршрутизатора на Go это очень просто. Это так из-за того, что организовывать работу с HTTP-обработчиками можно, используя компоновку. Но тут я не стану потакать своему желанию написать всё самому. Вместо этого предлагаю поговорить о том, как организовать систему маршрутизации с использованием одного из самых популярных маршрутизаторов, который называется gorilla/mux.

Сервер приложения для управления задачами, использующий gorilla/mux


Пакет gorilla/mux представляет собой один из самых старых и самых популярных HTTP-маршрутизаторов для Go. Слово mux, в соответствии с документацией к пакету, расшифровывается как HTTP request multiplexer (Мультиплексор HTTP-запросов) (такое же значение mux имеет и в стандартной библиотеке).

Так как это пакет, нацеленный на решение единственной узкоспециализированной задачи, пользоваться им очень просто. Вариант нашего сервера, в котором для маршрутизации используется gorilla/mux, можно найти здесь. Вот код определения маршрутов:

router := mux.NewRouter()router.StrictSlash(true)server := NewTaskServer()router.HandleFunc("/task/", server.createTaskHandler).Methods("POST")router.HandleFunc("/task/", server.getAllTasksHandler).Methods("GET")router.HandleFunc("/task/", server.deleteAllTasksHandler).Methods("DELETE")router.HandleFunc("/task/{id:[0-9]+}/", server.getTaskHandler).Methods("GET")router.HandleFunc("/task/{id:[0-9]+}/", server.deleteTaskHandler).Methods("DELETE")router.HandleFunc("/tag/{tag}/", server.tagHandler).Methods("GET")router.HandleFunc("/due/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/", server.dueHandler).Methods("GET")

Обратите внимание на то, что одни только эти определения тут же закрывают первые два пункта вышеприведённого списка задач, которые надо решить для повышения удобства работы с маршрутами. Благодаря тому, что в описании маршрутов используются вызовы Methods, мы можем с лёгкостью назначать в одном маршруте разные методы для разных обработчиков. Поиск совпадений с шаблонами (с использованием регулярных выражений) в путях позволяет нам легко различать /task/ и /task/<taskid> на самом верхнем уровне описания маршрутов.

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

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {log.Printf("handling get task at %s\n", req.URL.Path)// Тут и в других местах мы не проверяем ошибку Atoi, так как маршрутизатор// принимает лишь данные, проверенные регулярным выражением [0-9]+.id, _ := strconv.Atoi(mux.Vars(req)["id"])ts.Lock()task, err := ts.store.GetTask(id)ts.Unlock()if err != nil {http.Error(w, err.Error(), http.StatusNotFound)return}renderJSON(w, task)}

В определении маршрутов маршрут /task/{id:[0-9]+}/ описывает регулярное выражение, используемое для разбора пути и назначает идентификатор переменной id. К этой переменной можно обратиться, вызвав функцию mux.Vars с передачей ей req (эту переменную gorilla/mux хранит в контексте каждого запроса, а mux.Vars представляет собой удобную вспомогательную функцию для работы с ней).

Сравнение различных подходов к организации маршрутизации


Вот как выглядит последовательность чтения кода, применяемая в исходном варианте сервера тем, кто хочет разобраться в том, как обрабатывается маршрут GET /task/<taskid>.


А вот что нужно прочитать тому, кто хочет понять код, в котором применяется gorilla/mux:


При использовании gorilla/mux придётся не только меньше прыгать по тексту программы. Тут, кроме того, читать придётся гораздо меньший объём кода. По моему скромному мнению это очень хорошо с точки зрения улучшения читабельности кода. Описание путей при использовании gorilla/mux это простая задача, при решении которой нужно написать лишь небольшой объём кода. И тому, кто читает этот код, сразу понятно то, как этот код работает. Ещё одно преимущество такого подхода заключается в том, что все маршруты можно увидеть буквально раз взглянув на код, расположенный в одном месте. И, на самом деле, код настройки маршрутов выглядит теперь очень похожим на описание нашего REST API, выполненное в произвольной форме.

Мне нравится пользоваться такими пакетами, как gorilla/mux, из-за того, что подобные пакеты представляют собой узкоспециализированные инструменты. Они решают одну единственную задачу и решают её хорошо. Они не забираются в каждый уголок программного кода проекта, а значит, их, при необходимости можно легко убрать или заменить чем-то другим. Если вы посмотрите полный код того варианта сервера, о котором мы говорим в этой статье, то сможете увидеть, что область использования механизмов gorilla/mux ограничена несколькими строками кода. Если, по мере развития проекта, в пакете gorilla/mux будет обнаружено какое-то ограничение, несовместимое с особенностями этого проекта, задача замены gorilla/mux на другой маршрутизатор стороннего разработчика (или на собственный маршрутизатор) должна решаться достаточно быстро и просто.

Какой маршрутизатор вы использовали бы при разработке REST-сервера на Go?


Подробнее..

Перевод Разработка REST-серверов на Go. Часть 3 использование веб-фреймворка Gin

17.06.2021 16:13:58 | Автор: admin
Сегодня, в третьей части серии материалов, посвящённых разработке серверов на Go, мы займёмся реализацией нашего REST-сервера с использованием Gin одного из самых популярных веб-фреймворков для Go. Вот код, который мы будем тут обсуждать.



Выбор веб-фреймворка


В современной экосистеме Go имеется несколько популярных веб-фреймворков. И я уверен в том, что у каждого из них есть собственные сильные стороны. Моя цель заключается не в том, чтобы устраивать масштабные сравнения этих фреймворков и подробно их обсуждать. Меня интересует вопрос о том, как код, при написании которого используется некий фреймворк, соотносится с кодом, в котором фреймворки не используются.

Я выбрал именно Gin из-за того, что это один из самых популярных проектов такого рода (если судить по количеству GitHub-звёзд). Этот фреймворк кажется минималистичным, возникает такое ощущение, что с ним будет легко работать. Состояние документации к нему оставляет желать лучшего, но сам фреймворк настолько понятен, что мне, несмотря на это, было достаточно легко в нём разобраться и начать им пользоваться.

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

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

Маршрутизация и Gin


Наша функция main настраивает новый маршрутизатор Gin и регистрирует маршруты:

router := gin.Default()server := NewTaskServer()router.POST("/task/", server.createTaskHandler)router.GET("/task/", server.getAllTasksHandler)router.DELETE("/task/", server.deleteAllTasksHandler)router.GET("/task/:id", server.getTaskHandler)router.DELETE("/task/:id", server.deleteTaskHandler)router.GET("/tag/:tag", server.tagHandler)router.GET("/due/:year/:month/:day", server.dueHandler)

Вызов gin.Default() возвращает новый экземпляр Engine основного типа данных Gin, который не только играет роль маршрутизатора, но и даёт нам другой функционал. В частности, Default регистрирует базовое ПО промежуточного уровня, используемое при восстановлении после сбоев и для логирования. Подробнее о таком ПО мы поговорим позже.

Вышеприведённый код регистрации маршрутов должен показаться вам знакомым. А именно, он немного похож на тот код, который использовался в gorilla-версии нашего сервера. Но в нём есть и некоторые отличия:

  1. Вместо указания HTTP-метода в виде дополнительного (Go) вызова метода в маршруте, метод закодирован в имени функции, используемой для регистрации маршрута. Например тут используется конструкция вида router.POST, а не что-то вроде router.HandleFunc(...).Methods(POST).
  2. Gorilla поддерживает обработку запросов с использованием регулярных выражений. А Gin нет. К этому ограничению мы ещё вернёмся.

Обработчики запросов


Посмотрим на код обработчиков запросов, используемых при применении Gin. Начнём с самых простых, в частности с getAllTasksHandler:

func (ts *taskServer) getAllTasksHandler(c *gin.Context) {allTasks := ts.store.GetAllTasks()c.JSON(http.StatusOK, allTasks)}

Тут стоит обратить внимание на несколько интересных моментов:

  1. У обработчиков, используемых в Gin, нет стандартных сигнатур HTTP-обработчиков Go. Они просто принимают объект gin.Context, который может быть использован для анализа запроса и для формирования ответа. Но в Gin есть механизмы для взаимодействия со стандартными обработчиками вспомогательные функции gin.WrapF и gin.WrapH.
  2. В отличие от ранней версии нашего сервера, тут нет нужды вручную писать в журнал сведения о запросах, так как стандартный механизм логирования Gin, представленный ПО промежуточного уровня, сам решает эту задачу (и делается это с использованием всяческих полезных мелочей, вроде оформления вывода разными цветами и включения в журнал сведений о времени обработки запросов).
  3. Нам, кроме того, больше не нужно самостоятельно реализовывать вспомогательную функцию renderJSON, так как в Gin есть собственный механизм Context.JSON, который позволяет формировать JSON-ответы.

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

func (ts *taskServer) getTaskHandler(c *gin.Context) {id, err := strconv.Atoi(c.Params.ByName("id"))if err != nil {c.String(http.StatusBadRequest, err.Error())return}task, err := ts.store.GetTask(id)if err != nil {c.String(http.StatusNotFound, err.Error())return}c.JSON(http.StatusOK, task)}

Тут особенно интересно выглядит обработка параметров. Gin позволяет обращаться к параметрам маршрута (к тому, что начинается с двоеточия, вроде :id) через Context.Params.

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

Привязка данных запросов


И последний обработчик запросов, который мы рассмотрим, это createTaskHandler. Он обрабатывает запросы, которые включают в себя особые данные, поэтому с ним интересно будет познакомиться поближе:

func (ts *taskServer) createTaskHandler(c *gin.Context) {type RequestTask struct {Text string  `json:"text"`Tags []string `json:"tags"`Due time.Time `json:"due"`}var rt RequestTaskif err := c.ShouldBindJSON(&rt); err != nil {c.String(http.StatusBadRequest, err.Error())}id := ts.store.CreateTask(rt.Text, rt.Tags, rt.Due)c.JSON(http.StatusOK, gin.H{"Id": id})}

В Gin имеется серьёзная инфраструктура для организации привязки запросов к структурам данных Go, содержащих данные из запросов. Тут под привязкой понимается обработка содержимого запросов (которое может быть представлено данными в различных форматах, например JSON и YAML), проверка полученных данных и запись соответствующих значений в структуры Go. Здесь мы пользуемся весьма примитивной формой привязки данных для RequestTask, где проверка данных не используется. Но, полагаю, нам стоит знать не только о базовых, но и о более продвинутых возможностях Gin.

Можно заметить, что Gin-версия createTaskHandler существенно короче более ранних версий аналогичного обработчика, так как за разбор JSON-данных запроса отвечает ShouldBindJSON.

Ещё внимание обратить стоит на то, что теперь нам не нужно пользоваться одноразовой структурой для ID ответа. Вместо этого мы используем gin.H псевдоним для map[string]interface{}; это очень просто, но, всё же, позволяет весьма эффективно конструировать ответы, используя совсем небольшие объёмы кода.

Дополнительные возможности Gin


В нашем примере мы изучили лишь малую долю того, что Gin может предложить разработчикам веб-приложений. У Gin имеется множество стандартных дополнительных возможностей, вроде часто используемого ПО промежуточного уровня, системы аутентификации и вспомогательных механизмов для вывода HTML-шаблонов. Всё это несложно реализовать и без использования фреймворка, но использование Gin, определённо, ускорит решение соответствующих задач и позволит, по крайней мере, в простых случаях, обойтись скромными объёмами кода.

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

Ограничения фреймворков


Обратная сторона удобства работы с веб-фреймворками это их ограничения и непривычные стилистические особенности кода, который пишут с их использованием. Мы уже столкнулись с подобным ограничением в нашем простом примере. Речь идёт об отсутствии поддержки регулярных выражений в системе маршрутизации Gin. А это значит, что обработка любого необычного маршрута, его разбор и проверка, потребуют писать больше кода.

У любого программного пакета, у любого инструмента могут быть ограничения, но в случае с фреймворками такие ограничения способны оказывать довольно сильное влияние на программные проекты. Это так за счёт того, что фреймворки нацелены на решение достаточно масштабных задач, за счёт того, что они глубоко проникают в разные части проектов.

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

А теперь давайте представим, что у нас имеется большое веб-приложение, написанное с применением Gin. Мы неожиданно выясняем, что ограничение, связанное с регулярными выражениями, несовместимо с проектом (вряд ли так случится на самом деле, но, всё равно, это хороший пример). Но мы не можем просто взять и быстро заменить Gin на другой фреймворк, так как на Gin основано всё наше приложение. Перевод проекта на другой фреймворк потребует очень много времени и сил.

Недостатки фреймворков, однако, нельзя назвать несовместимыми с жизнью. И я не пытаюсь кого-то убеждать в том, что ему нужен или не нужен фреймворк. Я лишь стремлюсь показать объективную реальность и описать некоторые проблемы, с которыми сталкиваются программисты, использующие на практике различные программные пакеты и фреймворки.

Каким фреймворком вы воспользовались бы при разработке сервера на Go?


Подробнее..

Как писать кодогенераторы в Go

03.06.2021 16:16:28 | Автор: admin

Однообразный код писать неинтересно, нудно, но приходится. Испокон веков изворотливые программисты ищут Святой Грааль формализма, позволяющего переложить рутинные задачи на машину, писать только раз и переиспользовать код. Так появились структурное программирование, потом объектно-ориентированное, полиморфизм с параметризованными типами, кодогенерация на основе формальных грамматик, препроцессоры макроязыка и прочее Под катом рассмотрим, как обстоят дела именно в Go.


В Go на сегодня generics нет (хоть третий год и обещают), а выписывать по шаблону GetMax([]MyType) для каждого MyType надоедает.

Параметрический полиморфизм можно реализовать генерацией частных форм обобщённого кода на стадии компиляции (или выполнения) и поддержкой таблиц соответствия на стадии выполнения. В Go поддерживаются таблицы методов для типов и интерфейсов и диспетчеризация этих таблиц просто, зато эффективно реализовано.

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

Стандартного препроцессора в Go тоже нет. Зато есть директива go:generate и есть доступ к потрохам компилятора, в частности к дереву разбора (Abstract Syntax Tree), в пакетах go/ стандартной библиотеки. Это в совокупности даёт инструментарий богаче, чем препроцессор макросов.

Идиоматическое применение интерфейсов реализовано в stdlib-пакете sort, интроспекция применяется в пакетах encoding и fmt, go:generate в придворном пакете golang.org/x/tools/cmd/stringer.

Манипулирование AST исходного кода не очень распространено, потому что:

  • кодогенерацию трудно верифицировать;

  • дерево разбора кажется сложным, непонятным и пугает.

Как раз на использовании AST в быту мы и остановимся.

Go- и JS-разработчик Открытой мобильной платформы Дима Смотров рассказал, как писать кодогенераторы в Go и оптимизировать работу над микросервисами с помощью создания инструмента для генерации шаблонного кода.Статья составлена на основе выступления Димы на GopherCon Russia 2020.

О продуктах и компонентах на Go

Наша команда разрабатывает мобильную ОС Аврора, SDK и экосистему приложений под неё, доверенную среду исполнения Аврора ТЕЕ, систему по управлению корпоративной мобильной инфраструктурой Аврора Центр, включающую несколько коробочных продуктов и компонентов.

Группа Дмитрия, в частности, работает над продуктом Аврора Маркет, который обеспечивает управление дистрибуцией приложений. Его бэкенд полностью написан на Go.

В Go принято отдавать предпочтение явному программированию (explicit) в противовес неявному (implicit). Это помогает новым разработчикам легче начинать работать над существующими проектами. Но по пути от неявного программирования к явному можно легко заблудиться и забрести в дебри дубляжа кода, а дубляж кода в дальнейшем превратит поддержку проекта в ад.

Чтобы этого избежать, код выносят в отдельные модули. Но как насчёт кода, который пишется специально для каждого микросервиса и не может быть вынесен в модуль? Например, код репозитория для работы с базой данных. Этот код есть в каждом микросервисе, выглядит примерно одинаково, но он разный и не дублируется. Не хочется писать шаблонный код, который потом придётся ещё и поддерживать во всех микросервисах.

Кодогенерация официальный инструмент от авторов Go

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

И хотя в Go принято отдавать предпочтение явному программированию, разработчики предоставили инструменты для метапрограммирования, такие как кодогенерация ($go help generate) и Reflection API. Reflection API используется на этапе выполнения программы, кодогенерация перед этапом компиляции. Reflection API увеличивает время работы программы. Пример: инструмент для кодирования и декодирования JSON из стандартной библиотеки Go использует Reflection API. Взамен ему сообществом были рождены такие альтернативы, как easyjson, который с помощью кодогенерации кодирует и декодирует JSON в 5 раз быстрее.

Так как кодогенерация неявное программирование, она недооценивается сообществом Go, хотя и является официальным инструментом от создателей этого языка программирования. Поэтому в интернете немного информации о написании кодогенераторов на Go. Но всё же на Хабре примеры есть: 1 и 2.

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

Пример дублирующего кода:

type UserRepository struct{ db *gorm.DB }func NewRepository(db *gorm.DB) UserRepository {    return UserRepository{db: db}}func (r UserRepository) Get(userID uint) (*User, error) {    entity := new(User)    err := r.db.Limit(limit: 1).Where(query: "user_id = ?", userID).Find(entity).Error    return entity, err}func (r UserRepository) Create(entity *User) error {    return r.db.Create(entity).Error}func (r UserRepository) Update(entity *User) error {    return r.db.Model(entity).Update(entity).Error}func (r UserRepository) Delete(entity *User) error {    return r.db.Delete(entity).Error}

Про удачные кодогенераторы

Из примеров написанных и удачно используемых в нашей команде кодогенераторов хотим подробнее рассмотреть генератор репозитория по работе с базой данных. Нам нравится переносить опыт из одного языка программирования в другой. Так, наша команда попыталась перенести идею генерации репозиториев по работе с базой данных из Java Spring (https://spring.io/).

В Java Spring разработчик описывает интерфейс репозитория, исходя из сигнатуры метода автоматически генерируется реализация в зависимости от того, какой бэкенд для базы данных используется: MySQL, PostgreSQL или MongoDB. Например, для метода интерфейса с сигнатурой FindTop10WhereNameStartsWith (prefix string) автоматически генерируется реализация метода репозитория, которая вернёт до 10 записей из базы данных, имя которых начинается с переданного в аргументе префикса.

О нюансах и траблах внедрения кодогенератора

Существует парадигма Monolith First, когда пишут первую версию как монолит, а потом распиливают на микросервисы. На заре новой версии проекта, когда все команды должны были разбить монолит на микросервисы, мы решили написать свой генератор, который:

  • позволит вводить в систему новые микросервисы с меньшими усилиями, чем при его создании вручную (копируя предыдущий и удаляя лишнее);

  • сократит время на код-ревью за счёт общего шаблона для генерируемых микросервисов;

  • сократит время на будущие обновления одинакового кода микросервисов (main, инфрастуктура, etc).

Для разработки микросервисов командами было принято решение использовать go-kit. За основу мы взяли один из популярных существующих кодогенераторов для go-kit и стали его дорабатывать под наши требования для микросервисов. Он был написан с использованием не очень удобной библиотеки, которая использовала промежуточные абстракции для генерации кода Go. Код получался громоздким и трудным для восприятия и поддержки. В будущих версиях мы отказались от такого подхода и начали генерировать код Go с помощью шаблонов Go. Это позволило нам писать тот же самый код без каких-либо промежуточных абстракций. За пару недель нашей командой был написан прототип. А ещё через месяц был написан кодогенератор для go-kit, который буквально умел делать всё.

Разработчик описывает интерфейс go-kit-сервиса, а кодогенератор генерирует сразу всё, что для сервиса нужно:

  • CRUD-эндпоинты и REST-, gRPC- и NATS-транспорты;

  • репозиторий для работы с базой данных с возможностью расширять интерфейс репозитория;

  • main для всех go-kit-сервисов.

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

Позже наша команда работала над проектом, где нужно было больше заниматься адаптацией существующих Open-Source-продуктов под наши требования, чем разработкой новых микросервисов. Мы больше не могли заниматься кодогенератором, так как не использовали его активно в работе. А впоследствии мы тоже начали копировать сервисы вместе со всеми их недостатками.

Когда наша команда вернулась к разработке своего основного продукта, мы провели ретроспективу кодогенератора и поняли, в чём была основная проблема. Кодогенераторы, которые генерируют ВСЁ, сложно внедрять и поддерживать.

  • Кодогенератор генерировал слишком много кода.

  • Весь код нужно было ревьювить и перерабатывать.

  • Только часть команд решила пользоваться кодогенератором.

  • Получили сегментацию микросервисов.

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

Как же всё-таки генерировать Go-код

Можно просто использовать шаблоны. Можно написать шаблон и начинить его параметрами, на это вполне способны продвинутые редакторы текста. Можно использовать неинтерактивные редакторы sed или awk, порог входа круче, зато лучше поддаётся автоматизации и встраивается в производственный конвейер. Можно использовать специфические инструменты рефакторинга Go из пакета golang.org/x/tools/cmd, а именно gorename или eg. А можно воспользоваться пакетом text/template из стандартной библиотеки решение достаточно гибкое, человекочитаемое (в отличие от sed), удобно интегрируется в pipeline и позволяет оставаться в среде одного языка.

И всё же для конвейерной обработки этого маловато: требует существенного вмешательства оператора.

Можно пойти по проторённому пути: gRPC, Protobuf, Swagger. Недостатки подхода:

  • привязывает к gRPC, Protobuf;

  • не заточен конкретно под Go, а, напротив, требует изучения и внедрения новых, сторонних абстракций и технологий.

Чтобы остаться в родных пенатах воспользуемся средствами из стандартной библиотеки пакетами go/:

  • go/ast декларирует типы дерева разбора;

  • go/parser разбирает исходный код в эти типы;

  • go/printer выливает AST в файл исходного кода;

  • go/token обеспечивает привязку дерева разбора к файлу исходного кода.

Можно сделать шаблон, разобрать его AST и манипулировать этим деревом, подгоняя под нужный вид. Основной недостаток метода плохая читаемость манипуляций с AST и трудоёмкость верификации.

Можно вытащить из AST параметры, вмонтировать в шаблон и всё. Недостаток разрозненные обработки неудобно собирать в конвейер.

Поэтому выбран такой алгоритм кодогенерации:

  1. Разбираем AST исходного файла.

  2. Создаём пустое AST для генерируемого файла.

  3. Генерируем код из шаблонов Go (template/text).

  4. Разбираем AST сгенерированного кода.

  5. Копируем узлы AST из сгенерированного кода в AST генерируемого файла.

  6. Печатаем и сохраняем AST генерируемого файла в файл.

Чтобы было понятней и не пугала загадочная аббревиатура AST дерево разбора Hello World:

package mainimport "fmt"func main() {    fmt.Println("Hello, World!")}

...выглядит вот так:

...или вот так, напечатанное специализированным принтером ast.Print():

ast.Print
0  *ast.File {1  .  Package: 2:12  .  Name: *ast.Ident {3  .  .  NamePos: 2:94  .  .  Name: "main"5  .  }6  .  Decls: []ast.Decl (len = 2) {7  .  .  0: *ast.GenDecl {8  .  .  .  TokPos: 4:19  .  .  .  Tok: import10  .  .  .  Lparen: -11  .  .  .  Specs: []ast.Spec (len = 1) {12  .  .  .  .  0: *ast.ImportSpec {13  .  .  .  .  .  Path: *ast.BasicLit {14  .  .  .  .  .  .  ValuePos: 4:815  .  .  .  .  .  .  Kind: STRING16  .  .  .  .  .  .  Value: "\"fmt\""17  .  .  .  .  .  }18  .  .  .  .  .  EndPos: -19  .  .  .  .  }20  .  .  .  }21  .  .  .  Rparen: -22  .  .  }23  .  .  1: *ast.FuncDecl {24  .  .  .  Name: *ast.Ident {25  .  .  .  .  NamePos: 6:626  .  .  .  .  Name: "main"27  .  .  .  .  Obj: *ast.Object {28  .  .  .  .  .  Kind: func29  .  .  .  .  .  Name: "main"30  .  .  .  .  .  Decl: *(obj @ 23)31  .  .  .  .  }32  .  .  .  }33  .  .  .  Type: *ast.FuncType {34  .  .  .  .  Func: 6:135  .  .  .  .  Params: *ast.FieldList {36  .  .  .  .  .  Opening: 6:1037  .  .  .  .  .  Closing: 6:1138  .  .  .  .  }39  .  .  .  }40  .  .  .  Body: *ast.BlockStmt {41  .  .  .  .  Lbrace: 6:1342  .  .  .  .  List: []ast.Stmt (len = 1) {43  .  .  .  .  .  0: *ast.ExprStmt {44  .  .  .  .  .  .  X: *ast.CallExpr {45  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {46  .  .  .  .  .  .  .  .  X: *ast.Ident {47  .  .  .  .  .  .  .  .  .  NamePos: 7:248  .  .  .  .  .  .  .  .  .  Name: "fmt"49  .  .  .  .  .  .  .  .  }50  .  .  .  .  .  .  .  .  Sel: *ast.Ident {51  .  .  .  .  .  .  .  .  .  NamePos: 7:652  .  .  .  .  .  .  .  .  .  Name: "Println"53  .  .  .  .  .  .  .  .  }54  .  .  .  .  .  .  .  }55  .  .  .  .  .  .  .  Lparen: 7:1356  .  .  .  .  .  .  .  Args: []ast.Expr (len = 1) {57  .  .  .  .  .  .  .  .  0: *ast.BasicLit {58  .  .  .  .  .  .  .  .  .  ValuePos: 7:1459  .  .  .  .  .  .  .  .  .  Kind: STRING60  .  .  .  .  .  .  .  .  .  Value: "\"Hello, World!\""61  .  .  .  .  .  .  .  .  }62  .  .  .  .  .  .  .  }63  .  .  .  .  .  .  .  Ellipsis: -64  .  .  .  .  .  .  .  Rparen: 7:2965  .  .  .  .  .  .  }66  .  .  .  .  .  }67  .  .  .  .  }68  .  .  .  .  Rbrace: 8:169  .  .  .  }70  .  .  }71  .  }72  .  Scope: *ast.Scope {73  .  .  Objects: map[string]*ast.Object (len = 1) {74  .  .  .  "main": *(obj @ 27)75  .  .  }76  .  }77  .  Imports: []*ast.ImportSpec (len = 1) {78  .  .  0: *(obj @ 12)79  .  }80  .  Unresolved: []*ast.Ident (len = 1) {81  .  .  0: *(obj @ 46)82  .  }83  }

Хватит трепаться, покажите код

В целом задача сводится к тому, чтобы разработчик микросервиса мог описать свои данные обычным образом в стандартном синтаксисе Go, только добавить в комментариях директивы процессинга:

//repogen:entitytype User struct {    ID              uint `gorm:"primary_key"`    Email           string    PasswordHash    string}

...запустить go generate и получить вот такой файл с готовой обвязкой для работы с DB, в котором прописаны методы именно для его типа данных User:

User
type UserRepository struct{db *gorm.DB}func NewRepository(db *gorm.DB) UserRepository {    return UserRepository{db: db}}func (r UserRepository) Get(userID uint) (*User, error) {    entity := new(User)    err := r.db.Limit(limit: 1).Where(query: "user_id = ?", userID).Find(entity).Error    return entity, err}func (r UserRepository) Create(entity *User) error {    return r.db.Create(entity).Error}func (r UserRepository) Update(entity *User) error {    return r.db.Model(entity).Update(entity).Error}func (r UserRepository) Delete(entity *User) error {    return r.db.Delete(entity).Error}

Напишем такой генератор прямо здесь и сейчас, не применяя готовых решений, а ограничившись стандартной библиотекой.

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

Вот модель, для которой нам нужно сгенерировать методы работы с DB. В комментариях видны директивы:

  • go:generate repogen для команды go generate на запуск процессора repogen;

  • repogen:entity помечает цель для процессора repogen;

  • и тег поля структуры gorm:"primary_key" для процессора gorm помечает первичный ключ в таблице DB.

package gophercon2020//go:generate repogen//repogen:entitytype User struct {    ID              uint `gorm:"primary_key"`    Email           string    PasswordHash    string}

Вот код, собственно, процессора repogen:

Процессор repogen
package mainimport (    "bytes"    "go/ast"    "go/parser"    "go/printer"    "go/token"    "golang.org/x/tools/go/ast/inspector"    "log"    "os"    "text/template")//Шаблон, на основе которого будем генерировать//.EntityName, .PrimaryType  параметры,//в которые будут установлены данные, добытые из AST-моделиvar repositoryTemplate = template.Must(template.New("").Parse(`package mainimport (    "github.com/jinzhu/gorm")type {{ .EntityName }}Repository struct {    db *gorm.DB}func New{{ .EntityName }}Repository(db *gorm.DB) {{ .EntityName }}Repository {    return {{ .EntityName }}Repository{ db: db}}func (r {{ .EntityName }}Repository) Get({{ .PrimaryName }} {{ .PrimaryType}}) (*{{ .EntityName }}, error) {    entity := new({{ .EntityName }})    err := r.db.Limit(1).Where("{{ .PrimarySQLName }} = ?", {{ .PrimaryName }}).Find(entity).Error()    return entity, err}func (r {{ .EntityName }}Repository) Create(entity *{{ .EntityName }}) error {    return r.db.Create(entity).Error}func (r {{ .EntityName }}Repository) Update(entity *{{ .EntityName }}) error {    return r.db.Model(entity).Update.Error}func (r {{ .EntityName }}Repository) Update(entity *{{ .EntityName }}) error {    return r.db.Model(entity).Update.Error}func (r {{ .EntityName }}Repository) Delete(entity *{{ .EntityName }}) error {    return r.db.Delete.Error}`))//Агрегатор данных для установки параметров в шаблонеtype repositoryGenerator struct{    typeSpec    *ast.TypeSpec    structType  *ast.StructType}//Просто helper-функция для печати замысловатого ast.Expr в обычный stringfunc expr2string(expr ast.Expr) string {    var buf bytes.Buffer    err := printer.Fprint(&buf, token.NewFileSet(), expr)    if err !- nil {        log.Fatalf("error print expression to string: #{err}")    return buf.String()}//Helper для извлечения поля структуры,//которое станет первичным ключом в таблице DB//Поиск поля ведётся по тегам//Ищем то, что мы пометили gorm:"primary_key"func (r repositoryGenerator) primaryField() (*ast.Field, error) {    for _, field := range r.structType.Fields.List {        if !strings.Contains(field.Tag.Value, "primary")            continue        }        return field, nil    }    return nil, fmt.Errorf("has no primary field")}//Собственно, генератор//оформлен методом структуры repositoryGenerator,//так что параметры передавать не нужно://они уже аккумулированы в ресивере метода r repositoryGenerator//Передаём ссылку на ast.File,//в котором и окажутся плоды трудовfunc (r repositoryGenerator) Generate(outFile *ast.File) error {    //Находим первичный ключ    primary, err := r.primaryField()    if err != nil {        return err    }    //Аллокация и установка параметров для template    params := struct {        EntityName      string        PrimaryName     string        PrimarySQLName  string        PrimaryType     string    }{        //Параметры извлекаем из ресивера метода        EntityName      r.typeSpec.Name.Name,        PrimaryName     primary.Names[0].Name,        PrimarySQLName  primary.Names[0].Name,        PrimaryType     expr2string(primary.Type),    }    //Аллокация буфера,    //куда будем заливать выполненный шаблон    var buf bytes.Buffer    //Процессинг шаблона с подготовленными параметрами    //в подготовленный буфер    err = repositoryTemplate.Execute(&buf, params)    if err != nil {        return fmt.Errorf("execute template: %v", err)    }    //Теперь сделаем парсинг обработанного шаблона,    //который уже стал валидным кодом Go,    //в дерево разбора,    //получаем AST этого кода    templateAst, err := parser.ParseFile(        token.NewFileSet(),        //Источник для парсинга лежит не в файле,        "",        //а в буфере        buf.Bytes(),        //mode парсинга, нас интересуют в основном комментарии        parser.ParseComments,    )    if err != nil {        return fmt.Errorf("parse template: %v", err)    }    //Добавляем декларации из полученного дерева    //в результирующий outFile *ast.File,    //переданный нам аргументом    for _, decl := range templateAst.Decls {        outFile.Decls = append(outFile.Decls, decl)    }    return nil}func main() {    //Цель генерации передаётся переменной окружения    path := os.Getenv("GOFILE")    if path == "" {        log.Fatal("GOFILE must be set")    }    //Разбираем целевой файл в AST    astInFile, err := parser.ParseFile(        token.NewFileSet(),        path,        src: nil,        //Нас интересуют комментарии        parser.ParseComments,    )    if err != nil {        log.Fatalf("parse file: %v", err)    }    //Для выбора интересных нам деклараций    //используем Inspector из golang.org/x/tools/go/ast/inspector    i := inspector.New([]*ast.File{astInFile})    //Подготовим фильтр для этого инспектора    iFilter := []ast.Node{        //Нас интересуют декларации        &ast.GenDecl{},    }    //Выделяем список заданий генерации    var genTasks []repositoryGenerator    //Запускаем инспектор с подготовленным фильтром    //и литералом фильтрующей функции    i.Nodes(iFilter, func(node ast.Node, push bool) (proceed bool){        genDecl := node.(*ast.GenDecl)        //Код без комментариев не нужен,        if genDecl.Doc == nil {            return false        }        //интересуют спецификации типов,        typeSpec, ok := genDecl.Specs[0].(*ast.TypeSpec)        if !ok {            return false        }        //а конкретно структуры        structType, ok := typeSpec.Type.(*ast.StructType)        if !ok {            return false        }        //Из оставшегося        for _, comment := range genDecl.Doc.List {            switch comment.Text {            //выделяем структуры, помеченные комментарием repogen:entity,            case "//repogen:entity":                //и добавляем в список заданий генерации                genTasks = append(genTasks, repositoryGenerator{                    typeSpec: typeSpec,                    structType: structType,                })            }        }        return false    })    //Аллокация результирующего дерева разбора    astOutFile := &ast.File{        Name: astInFile.Name,    }    //Запускаем список заданий генерации    for _, task := range genTask {        //Для каждого задания вызываем написанный нами генератор        //как метод этого задания        //Сгенерированные декларации помещаются в результирующее дерево разбора        err = task.Generate(astOutFile)        if err != nil {            log.Fatalf("generate: %v", err)        }    }    //Подготовим файл конечного результата всей работы,    //назовем его созвучно файлу модели, добавим только суффикс _gen    outFile, err := os.Create(strings.TrimSuffix(path, ".go") + "_gen.go")    if err != nil {        log.Fatalf("create file: %v", err)    }    //Не забываем прибраться    defer outFile.Close()    //Печатаем результирующий AST в результирующий файл исходного кода    //Печатаем не следует понимать буквально,    //дерево разбора нельзя просто переписать в файл исходного кода,    //это совершенно разные форматы    //Мы здесь воспользуемся специализированным принтером из пакета ast/printer    err = printer.Fprint(outFile, token.NewFileSet(), astOutFile)    if err != nil {        log.Fatalf("print file: %v", err)    }}

Подводя итоги

Работа с деревом разбора в Go не требует сверхъестественных способностей. Язык предоставляет для этого вполне годный инструментарий. Кода получилось не слишком много, и он достаточно читаем и, надеемся, понятен. Высокой эффективности здесь добиваться нет нужды, потому что всё происходит ещё до стадии компиляции и на стадии выполнения издержек не добавляет (в отличие от reflect). Важнее валидность генерации и манипуляций с AST. Кодогенерация сэкономила нам достаточно времени и сил в написании и поддержке большого массива кода, состоящего из повторяющихся паттернов (микросервисов). В целом кодогенераторы оправдали затраты на своё изготовление. Выбранный pipeline показал себя работоспособным и прижился в производственном процессе. Из стороннего опыта можем рекомендовать к использованию:

  • dst (у которого лучше разрешение импортируемых пакетов и привязка комментариев к узлам AST, чем у go/ast из stdlib).

  • kit (хороший toolkit для быстрой разработки в архитектуре микросервисов. Предлагает внятные, рациональные абстракции, методики и инструменты).

  • jennifer (полноценный кодогенератор. Но его функциональность достигнута ценой применения промежуточных абстракций, которые хлопотно обслуживать. Генерация из шаблонов text/template на деле оказалась удобней, хоть и менее универсальной, чем манипулирование непосредственно AST с использованием промежуточных абстракций. Писать, читать и править шаблоны проще).

Набор узконаправленных генераторов оказался сподручней одного универсального и в применении, и в поддержке. Здесь допустима аналогия микросервисы vs монолит. Делитесь опытом и мнениями в комментариях.

Подробнее..

Перевод Языки любимые и языки страшные. Зелёные пастбища и коричневые поля

07.05.2021 14:19:42 | Автор: admin


Результаты опроса Stack Overflow являются отличным источником информации о том, что происходит в мире разработки. Я просматривал результаты 2020 года в поисках некоторых идей, какие языки добавить в нашу документацию по контейнерным сборкам, и заметил кое-что интересное о типах языков. Мне кажется, это не часто встречается в различных дискуссиях о предпочтениях разработчиков.

В опросах есть категории Самые страшные языки программирования (The Most Dreaded Programming Languages) и Самые любимые языки. Оба рейтинга составлены на основе одного вопроса:

На каких языках вы провели обширную работу по разработке за последний год, и на каких хотите работать в следующем году? (Если вы работаете с определённым языком и намерены продолжать это делать, пожалуйста, установите оба флажка).

Страшный язык это такой, с которым вы активно работаете в текущем году, но не хотите продолжать его использовать. Любимый язык тот, который вы широко используете и хотите продолжать использовать. Результаты интересны тем, что отражают мнения людей, которые активно используют каждый язык. Не учитываются мнения типа Я слышал, что Х это круто, когда люди высоко оценивают вещи, которые они НЕ используют, потому что они слышали, что это новый тренд. Обратное тоже правда: люди, которые выражают отвращение к какому-то языку, реально широко используют его. Они боятся языка не потому, что слышали о его сложности, а потому, что им приходится работать с ним и испытывать настоящую боль.

Топ-15 страшных языков программирования:
VBA, Objective-C, Perl, Assembly, C, PHP, Ruby, C++, Java, R, Haskell, Scala, HTML, Shell и SQL.

Топ-15 любимых языков программирования:
Rust, TypeScript, Python, Kotlin, Go, Julia, Dart, C#, Swift, JavaScript, SQL, Shell, HTML, Scala и Haskell.

В списке есть закономерность. Заметили?

Худший код тот, что написан до меня


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

Причина, по которой [разработчики] считают старый код бардаком, заключается в кардинальном, фундаментальном законе программирования: читать код труднее, чем писать его.

Джоэл Спольски Грабли, на которые не стоит наступать

Назовём это Законом Джоэла. Из этой посылки вытекает многое. Почему большинство разработчиков думают, что унаследованный ими код это бардак, и хотят выбросить его и начать всё сначала? Потому что написание чего-то нового проще для мозга, чем тяжёлая работа по пониманию существующей кодовой базы, по крайней мере, на начальном этапе. Почему попытки переписать код часто обречены на провал? Потому что многие мусорные артефакты это жизненно важные небольшие улучшения, которые накапливаются с течением времени. Без какого-то конкретного плана по рефакторингу вы в конечном итоге вернётесь к тому, с чего начали.


Scott Adams Understood

Легко понять код, который вы пишете. Вы его выполняете и совершенствуете по ходу дела. Но трудно понять код, просто прочитав его постфактум. Если вы вернётесь к своему же старому коду то можете обнаружить, что он непоследовательный. Возможно, вы выросли как разработчик и сегодня бы написали лучше. Но есть вероятность, что код сложен по своей сути и вы интерпретируете свою боль от понимания этой сложности как проблему качества кода. Может, именно поэтому постоянно растёт объём нерассмотренных PR? Ревью пул-реквестов работа только на чтение, и её трудно сделать хорошо, когда в голове ещё нет рабочей модели кода.

Вот почему вы их боитесь


Если реальный старый код незаслуженно считают бардаком, то может и языки программирования несправедливо оцениваются? Если вы пишете новый код на Go, но должны поддерживать обширную 20-летнюю кодовую базу C++, то способны ли справедливо их ранжировать? Думаю, именно это на самом деле измеряет опрос: страшные языки, вероятно, будут использоваться в существующих проектах на коричневом поле. Любимые языки чаще используются в новых проектах по созданию зелёных пастбищ. Давайте проверим это.1

Сравнение зелёных и коричневых языков


Индекс TIOBE измеряет количество квалифицированных инженеров, курсов и рабочих мест по всему миру для языков программирования. Вероятно, есть некоторые проблемы в методологии, но она достаточно точна для наших целей. Мы используем индекс TIOBE за июль 2016 года, самый старый из доступных в Wayback Machine, в качестве прокси для определения языков, накопивших много кода. Если язык был популярным в 2016 году, скорее всего, люди поддерживают написанный на нём код.

Топ-20 языков программирования в списке TIOBE по состоянию на июль 2016 года: Java, C, C++, Python, C#, PHP, JavaScript, VB.NET, Perl, ассемблер, Ruby, Pascal, Swift, Objective-C, MATLAB, R, SQL, COBOL и Groovy. Можем использовать это в качестве нашего списка языков, которые с большей вероятностью будут использоваться в проектах по поддержке кода. Назовём их коричневыми языками. Языки, не вошедшие в топ-20 в 2016 году, с большей вероятностью будут использоваться в новых проектах. Это зелёные языки.


Из 22 языков в объединённом списке страшных/любимых 63% коричневых

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

Java, C, C++, C#, Python, PHP, JavaScript, Swift, Perl, Ruby, Assembly, R, Objective-C, SQL


Зелёный язык: язык, который вы с большей вероятностью будете использовать в новом проекте.

Go, Rust, TypeScript, Kotlin, Julia, Dart, Scala и Haskell

У TIOBE и StackOverflow разные представления о том, что такое язык программирования. Чтобы преодолеть это, мы должны нормализовать два списка, удалив HTML/CSS, шелл-скрипты и VBA.2

Конечно, простое деление на зелёные и коричневые упускает много нюансов, в том числе по размеру полей. Я ожидаю, что больше зелёных пастбищ должно быть на Swift, чем на Objective-C, но и нынешняя методика, кажется, охватывает всё, что нам нужно. В этом списке гораздо больше коричневых языков, чем зелёных, но это вполне ожидаемо, ведь новых языков ежегодно появляется относительно немного.

Теперь можно ответить на вопрос: люди действительно боятся языков или же они просто боятся старого кода? Или скажем иначе: если бы Java и Ruby появились сегодня, без груды старых приложений Rails и старых корпоративных Java-приложений для поддержки, их всё ещё боялись бы? Или они с большей вероятностью появились бы в списке любимых?

Страшные коричневые языки



Страшные языки на 83% коричневые

Топ страшных языков почти полностью коричневый: на 83%. Это более высокий показатель, чем 68% коричневых языков в полном списке.

Любимые зелёные языки



Любимые языки на 54% зелёные

Среди любимых языков 54% зелёных. В то же время в полном списке всего лишь 36% языков являются зелёными. И каждый зелёный язык есть где-то в списке любимых.

Ещё один недостаток человеческого характера заключается в том, что все хотят строить и никто не хочет заниматься обслуживанием.

Курт Воннегут

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

Другими словами, Rust, Kotlin и другие зелёные языки пока находятся на этапе медового месяца. Любовь к ним может объясняться тем, что программистам не надо разбираться с 20-летними кодовыми базами.

Устранение предвзятости




Некоторые новые или исторически менее популярные языки программирования могут быть лучше, чем старые или более распространённые языки, но наша способность судить о них кажется довольно предвзятой. В частности, если язык новый или ранее не использовался, то у него некий ангельский образ. А чем дольше используется язык, тем более дьявольский лик он приобретает в глазах разработчиков. Думаю, причина в том, что никому не нравится поддерживать чужой код. А также из-за Закона Джоэла: читать в реальном мире очень сложно. Создавать что-то новое вот это весело, а для этого чаще используются новые языки.

Цикл хайпа языков программирования


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


Цикл хайпа языков программирования

У меня под рукой нет данных, но я отчётливо помню, что Ruby был самым популярным языком в 2007 году. И хотя сегодня у него больше конкурентов, но сегодня Ruby лучше, чем тогда. Однако теперь его боятся. Мне кажется, теперь у людей на руках появились 14-летние приложения Rails, которые нужно поддерживать. Это сильно уменьшает привлекательность Ruby по сравнению с временами, когда были одни только новые проекты. Так что берегитесь, Rust, Kotlin, Julia и Go: в конце концов, вы тоже лишитесь своих ангельских крылышек.3



1. Сначала я придумал критерии. Я не искал данных, подтверждающих первоначальную идею.

Была мысль определять статус зелёного или коричневого по дате создания языка, но некоторые старые языки нашли применение только относительно недавно.

Вот методика измерения TIOBE, а их исторические данные доступны только платным подписчикам, поэтому Wayback Machine. [вернуться]

2. HTML/CSS не являются тьюринг-полными языками, по этой причине TIOBE не считает их полноценными языками программирования. Шелл-скрипты измеряются отдельно, а VBA вообще не исследуется, насколько я понял. [вернуться]

3. Не все коричневые языки внушают страх: Python, C#, Swift, JavaScript и SQL остаются любимыми. Хотелось бы услышать какие-нибудь теории о причине этого феномена. Кроме того, Scala и Haskell два языка, к которым я питаю слабость единственные зелёные языки в страшном списке. Это просто шум или есть какое-то обоснование??? [вернуться]
Подробнее..

Factory Method Pattern

09.05.2021 20:20:27 | Автор: admin

Привет, друзья. С вами Alex Versus.

Ранее мы говорили про шаблоны проектирования Одиночка и Стратегия, про тонкости реализации на языке Golang.

Сегодня расскажу про Фабричный метод.

В чем суть?

Фабричный метод (Factory method) так же известный как Виртуальный конструктор(Virtual Constructor) - пораждающий шаблон проектирования, определяющий общий интерфес создания объектов в родительском классе и позволяющий изменять создаваемые объекты в дочерних классах.

Шаблон позволяет классу делегировать создание объектов подклассам. Используется, когда:

  1. Классу заранее неизвестно, объекты каких подклассов ему нужно создать.

  2. Обязанности делегируются подклассу, а знания о том, какой подкласс принимает эти обязанности, локализованы.

  3. Создаваемые объекты родительского класса специализируются подклассами.

Какую задачу решает?

Представьте, что вы создали программу управления доставкой еды. В программе в качестве единственного средства доставки используется электро-самокат. Ваши курьеры на электро-самокатах развозят еду из пункта А в пункт Б. Все просто.

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

Вы обнаруживаете, что большая часть ваших сущностей в программе сильно связаны с объектом Самокат и чтобы заставить вашу программу работать с другими способами доставки, вам придется добавить связи в 80% вашей кодовой базы и так повторить для каждого нового транспорта. Знакомая ситуация?

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

И какое решение?

Фабричный метод предлагает создавать объекты транспорта через вызов специального метода. Подклассы класса, который содержит фабричный метод могут изменять создаваемые объектов конкретных создаваемых транспортов. На первый взгляд, это может показаться бессмысленным: мы просто переместили вызов конструктора из одного конца программы в другой. Но теперь вы сможете переопределять фабричный метод в подклассе, чтобы изменить тип создаваемого транспорта.

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

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

Посмотрим на диаграмму классов такого подхода.

Диаграмма классов Factory MethodДиаграмма классов Factory Method

Реализация на Golang

Пример реализации на PHP, можно изучить тут. Так как в Golang отсутствуют возможности ООП, такие как классы и наследование, то реализовать в классическом виде этот шаблон невозможно. Несмотря на это, мы можем реализовать базовую версию шаблона - Простая фабрика.

В нашем примере есть файл iTransport.go, который определяет методы создаваемых транспортных средств для доставки еды. Сущность транспорта будем хранить в структуре (struct), которая применяет интерфейс iTransport.

Так же реализуем файл Factory.go, который представляет фабрику создания нужных объектов. Клиентский код реализован в файле main.go. Вместо прямого создания конкретных объектов транспорта клиентский код будет использовать для этого метод фабрики getTransport(t string), передавая нужный тип объекта в виде аргумента функции.

Когда применять?

  1. Когда хотим дать возможность расширять нашу библиотеку. Используя подход, пользователи вашей библиотеки могут создавать новые конкретные реализации классов, а создание объектов данных классов будет отведено фабричному методу вашей библиотеки.

  2. Фабричный метод отделяет код создания объектов от остального кода. Код создания объектов можно расширять, не трогая основной код программы. Для создания нового объекта вашего продукта, достаточно создать новый подкласс и определить в нем фабричный метод, возвращающий нужный продукт в нужной конфигурации.

Какие преимущества?

  1. Избавляет слой создания объектов от конкретных классов продуктов. Выделяет код производства продуктов в одно место, упрощая поддержку кода.

  2. Упрощает добавление новых продуктов в программу.

  3. Реализует принцип открытости/закрытости (англ. openclosed principle, OCP) принцип ООП, устанавливающий следующее положение: программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для изменения

Какие недостатки?

Может привести к созданию больших параллельных иерархий классов, так как для каждого класса продукта надо создать свой подкласс создателя.

Итог

Используйте шаблон Фабричный метод в случае, когда вы хотите без проблем внедрять в вашу программу новые объекты с новыми конфигурациям для взаимодействия с основной бизнес логикой.

Рад был поделиться материалом, Alex Versus. Публикация на английском.
Всем удачи!

Подробнее..

Перевод Запускаем Golang на Jupyter Notebook

13.05.2021 16:14:30 | Автор: admin

Если вы знакомы с Python, то уже сталкивались с Jupyter Notebook или работали в нём по крайней мере один раз. Jupyter Notebook это удобный инструмент, позволяющий писать мини-код и отслеживать его выполнение. Он также помогает в документировании, ведении журнала и в том, чтобы поделиться своими работами с коллегами.

Неудивительно, что многие люди и крупные организации, такие как Netflix, для своих целей в разработке предпочитают Jupyter Notebook. Специально к старту нового потока курса по разработке на Go 26 мая мы решили поделиться переводом, автор которого рассказывает, как документировать проекты на Golang в Jupyter Notebook.


Если вы работаете на машине с Windows, потребуется установка Docker. Пожалуйста, следуйте этим инструкциям. Если вы работаете на Mac или Linux, вы можете либо использовать метод с docker выше, либо следовать процессам локальной установки, о которых я напишу ниже.

Содержание

  1. Установка.

  2. Запуск Jupyter Notebook.

  3. Написание простой программы.

Установка

Установка может показаться сложной, но я постараюсь сделать её как можно проще. Если при настройке вы столкнулись с какими-либо трудностями, пожалуйста, обратитесь к FAQ по устранению неполадок gophernote.

Я приведу три основных метода установки, хотя сам рекомендовал бы подход с *Docker*, поскольку он не зависит от операционной системы, то есть сможет работать на любой операционной системе.

1. Докер (рекомендация)

Вот основная команда:

$ docker run -it -p 8888:8888 -v /path/to/local/notebooks:/path/to/notebooks/in/docker gopherdata/gophernotes:latest-ds

Тег latest-ds указывает докеру, чтобы он извлёк версию пакета gophernotes, где уже установленные библиотеки Data Science, такие как GoNum, GoLearn и GoDa. Команда на вашей машине может выглядеть так:

$ docker run -it -p 8888:8888 -v /home/user/Documents/notebook:/notebook gopherdata/gophernotes:latest-ds

Затем вам будут предоставлены URL-адрес локального хоста подключённого блокнота и соответствующий ему токен. Скопируйте и вставьте его в свой браузер (например localhost:8888/?token=<your_given_token>).

Успешное монтирование Notebok в Docker (изображение от автора)Успешное монтирование Notebok в Docker (изображение от автора)

Кроме того, вы сможете увидеть папку notebook, которую указали при инициализации контейнера docker в браузере.

Папка блокнота, которую вы указали, когда инициализировали Docker (изображение от автора)Папка блокнота, которую вы указали, когда инициализировали Docker (изображение от автора)

Следуйте приведённым ниже инструкциям, если предпочитаете локальную установку. Однако они работают только для Linux и Mac. Машины с Windows в настоящее время не поддерживаются, и вы должны использовать вышеупомянутый метод Docker.

2. Linux

Вот команды локальной установки для Linux:

$ env GO111MODULE=on go get github.com/gopherdata/gophernotes$ mkdir -p ~/.local/share/jupyter/kernels/gophernotes$ cd ~/.local/share/jupyter/kernels/gophernotes$ cp "$(go env GOPATH)"/pkg/mod/github.com/gopherdata/gophernotes@v0.7.2/kernel/*  "."$ chmod +w ./kernel.json # in case copied kernel.json has no write permission$ sed "s|gophernotes|$(go env GOPATH)/bin/gophernotes|" < kernel.json.in > kernel.json

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

$ "$(go env GOPATH)"/bin/gophernotes

И вы сможете открыть блокнот этой командой:

$ jupyter --data-dir

3. Mac

Аналогично локальную установку для Mac можно выполнить, написав в терминале следующие команды:

$ env GO111MODULE=on go get github.com/gopherdata/gophernotes$ mkdir -p ~/Library/Jupyter/kernels/gophernotes$ cd ~/Library/Jupyter/kernels/gophernotes$ cp "$(go env GOPATH)"/pkg/mod/github.com/gopherdata/gophernotes@v0.7.2/kernel/*  "."$ chmod +w ./kernel.json # in case copied kernel.json has no write permission$ sed "s|gophernotes|$(go env GOPATH)/bin/gophernotes|" < kernel.json.in > kernel.json

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

$ "$(go env GOPATH)"/bin/gophernotes

Теперь можно открыть блокнот этой командой:

$ jupyter --data-dir

Фух, переварить такое довольно трудно. Переходим к частям веселее!

Запуск Jupyter Notebook

Теперь, когда вы настроили Gophernotes, перейдите в папку, где хотите хранить свои блокноты Golang, там мы создадим наш первый блокнот! В правом верхнем углу вы увидите новую кнопку. Нажмите на неё и выберите "Go" в качестве ядра блокнота.

Создание первого блокнота Go в Jupyter (изображение от автора)Создание первого блокнота Go в Jupyter (изображение от автора)

Как только вы сделаете это, вас встретит знакомый чистый блокнот Jupyter. Теперь первым делом нужно изменить название на My First Golang Notebook (или любое другое, как показано ниже):

Изменение названия блокнотаИзменение названия блокнота

Давайте напишем какую-нибудь простую программу в наш Golang Notebook.

Рекурсивный факториал

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

Импорт пакетов в блокнот GoИмпорт пакетов в блокнот Go

Теперь напишем рекурсивный факториал. Факториал числа n это произведение всех положительных целых чисел, меньших или равных n. Например 3!, то есть факториал числа 3, это 3 x 2 x 1 = 6. Записать функцию вычисления факториала можно в одну из ячеек Jupyter Notebook:

Рекурсивный факториал на GoРекурсивный факториал на Go

Осталось только запустить программу, которая распечатывает значение, вот так:

Вызов рекурсивной функции вычисления факториала и печать значенияВызов рекурсивной функции вычисления факториала и печать значения

Бонус

Мощь Jupyter Notebook в возможности аннотирования и комментирования без загромождения кодовой базы. Воспользоваться этими возможностями можно, изменив тип ячейки на markdown, то есть выделить ячейку, нажать ctrl+M и ввести соответствующие примечания.

Комментирование и аннотирование кодовой базы (изображение от автора)Комментирование и аннотирование кодовой базы (изображение от автора)

Заключение

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

А если вам не хочется ограничиваться столь простыми программами на Go, обратите внимание на специальный курс Backend-разработчик на Go. У всех поступивших на курс появляется поддержка в виде экспертов, готовых ответить на вопросы и пояснить нюансы языка. Хотите дополнить свой арсенал навыков умением кодить на GO добро пожаловать. А по ссылке можно ознакомиться с программой курса.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Перевод Go справляемся с конфликтами при блокировках с помощью пакета Atomic

13.05.2021 20:21:56 | Автор: admin

Перевод материала подготовлен в рамках курса "Golang Developer. Professional". Если вам интересно узнать подробнее о курсе, приглашаем на день открытых дверей онлайн.


Эта статья берет за основу Go 1.14.

Go предоставляет механизмы синхронизации памяти, такие как канал (channel) или мьютекс (mutex ), которые помогают решать различные проблемы. Касательно разделяемой памяти, мьютекс защищает память от гонки данных. Однако, несмотря на существование двух типов мьютексов, в целях повышения производительности Go также предоставляет атомарные примитивы памяти в пакете atomic. Но давайте сначала вернемся к гонкам данных, прежде чем углубляться в решения.

Гонка данных

Гонка данных (data race) может возникать, когда две или более горутины одновременно обращаются к одной и той же области памяти, и хотя бы одна из них выполняет запись. В то время как map имеет собственный механизм защиты от гонки данных, простые структуры их не имеют, что делает их уязвимыми к этой проблеме.

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

Выполнение этого кода ясно показывает, что результат недетерминирован из-за гонки данных:

[...]&{[79167 79170 79173 79176 79179 79181]}&{[79216 79219 79220 79221 79222 79223]}&{[79265 79268 79271 79274 79278 79281]}

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

WARNING: DATA RACERead at 0x00c0003aa028 by goroutine 9:  [...]  fmt.Printf()      /usr/local/go/src/fmt/print.go:213 +0xb5  main.main.func2()      main.go:30 +0x3bPrevious write at 0x00c0003aa028 by goroutine 7:  main.main.func1()      main.go:20 +0xfe

Защита чтения и записи от гонок данных может быть реализована с помощью мьютекса или (что является наиболее распространенным решением) пакетом atomic.

Mutex vs Atomic

Стандартная библиотека предоставляет два вида мьютексов в пакете sync: sync.Mutex и sync.RWMutex; последний оптимизирован для случаев, когда ваша программа имеет дело с множеством читателей и очень небольшим количеством записывателей. Вот одно из решений:

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

[...]&{[213 214 215 216 217 218]}&{[214 215 216 217 218 219]}&{[215 216 217 218 219 220]}

Второе решение может быть выполнено благодаря пакету atomic. Вот код:

Результат также является вполне ожидаемым:

[...]&{[32724 32725 32726 32727 32728 32729]}&{[32733 32734 32735 32736 32737 32738]}&{[32753 32754 32755 32756 32757 32758]}

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

Производительность

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

Выполнение теста десять раз бок о бок дает следующие результаты:

name                              time/opAtomicOneWriterMultipleReaders-4  72.2ns  2%AtomicMultipleReaders-4           65.8ns  2%MutexOneWriterMultipleReaders-4    717ns  3%MutexMultipleReaders-4             176ns  2%

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

Для получения дополнительной информации о пакете trace я предлагаю вам прочитать мою статью Go: Discovery of the Trace Package..

Вот профиль программы, использующей пакет atomic:

Горутины работают без перерывов и могут выполнять свои задачи. Что касается профиля программы с мьютексом, картина совсем другая:

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

Время блокировки составляет примерно треть всего времени. Это можно детализировать из профиля блокирующего:

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

Для получения дополнительной информации о мьютексах я предлагаю вам прочитать мою статью Go: Mutex and Starvation.


Узнать подробнее о курсе "Golang Developer. Professional"

Смотреть демо-урок Форматирование данных

Подробнее..

Перевод Go Управление обработкой множественных ошибок

20.05.2021 16:23:11 | Автор: admin

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

Одна горутина, несколько ошибок

Объединение нескольких ошибок в одну может быть весьма полезным решением, например, когда вы работаете над кодом, имеющим политику повторного запуска. Вот простой пример, в котором нам нужно группировать сгенерированные ошибки:

Эта программа считывает и анализирует CSV-текст и отображает найденную ошибку. Было бы намного удобнее группировать ошибки, чтобы получить полный отчет. Чтобы объединить ошибки в одну, у нас есть выбор между двумя отличными пакетами:

  • Используя go-multierror от HashiCorp, несколько ошибок можно объединить в одну стандартную ошибку:

Затем можно вывести отчет:

Реализация здесь аналогична, вот результат:

Ошибки объединяются через точку с запятой без какого-либо другого форматирования.

Что касается производительности каждого пакета, вот бенчмарк на той же программе, но с большим количеством ошибок:

name                    time/op         alloc/op        allocs/opHashiCorpMultiErrors-4  6.01s  1%     6.78kB  0%     77.0  0%UberMultiErrors-4       9.26s  1%     10.3kB  0%      126  0%

Реализация Uber немного медленнее и потребляет больше памяти. Однако этот пакет был разработан для группировки ошибок после их сбора, а не для итеративного добавления при каждом их возникновении. При группировании ошибок результаты близки, но код менее элегантен, поскольку требуется дополнительный этап. Вот обновленные результаты:

name                    time/op         alloc/op        allocs/opHashiCorpMultiErrors-4  6.01s  1%     6.78kB  0%     77.0  0%UberMultiErrors-4       6.02s  1%     7.06kB  0%     77.0  0%

Оба пакета используют интерфейс Go error со своей реализацией функции Error() string.

Одна ошибка, несколько горутин

Чтобы обеспечить корректность вашей программы при работе на нескольких горутинах для выполнения одной задачи, необходимо правильно управлять результатами и агрегированием ошибок.

Начнем с программы, которая использует несколько горутин для выполнения ряда действий; каждое из них длится одну секунду:

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

Как и ожидалось, программа занимает примерно три секунды, поскольку большинству горутин необходимо выполнить три действия, каждое из которых занимает одну секунду:

go run .  0.30s user 0.19s system 14% cpu 3.274 total

Однако мы могли бы захотеть сделать горутины зависимыми друг от друга и отменять их, если одна из них даст сбой. В качестве решения, чтобы избежать ненужной работы, можно добавить контекст, чтобы, как только горутина даст сбой, она отменяла его:

Это именно то, что предоставляет errgroup; распространение ошибки и контекста при работе с группой горутин. Вот обновленный код, использующий пакет errgroup:

Теперь программы работают быстрее, поскольку они распространяют отмененный ошибкой контекст:

go run . 0.30s user 0.19s system 38% cpu 1.269 total

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


Перевод подготовлен в рамках набора студентов на курс "Golang Developer. Professional".

Всех желающих приглашаем на открытый вебинар Форматирование данных. На этом demo-занятии рассмотрим:
- кодировки quoted-printable и base64;
- текстовые форматы JSON, XML и YAML;
- использование структур и интерфейсов для парсинга данных;
- сравнение бинарных сериализаторов: gob, msgpack и protobuf.
После занятия вы сможете сериализовывать и десериализовывать данные различных форматов стандартными средствами языка и сторонними библиотеками. Присоединяйтесь!

Подробнее..

Prototype Design Pattern в Golang

24.05.2021 18:21:26 | Автор: admin

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

Интересно получать обратную связь от вас, понимать на сколько применима данная область знаний в мире языка Golang. Ранее уже рассмотрели шаблоны: Simple Factory, Singleton и Strategy. Сегодня хочу рассмотреть еще один шаблон проектирования - Prototype.

Для чего нужен?

Это порождающий шаблон проектирования, который позволяет копировать объекты, не вдаваясь в подробности их реализации.

Какую проблему решает?

Представьте, у вас есть объект, который необходимо скопировать. Как это сделать? Создать пустой объект такого же класса, затем поочерёдно скопировать значения всех полей из старого объекта в новый. Прекрасно, но есть нюанс! Не каждый объект удается скопировать таким образом, ведь часть его состояния может быть приватной, а значит - недоступной для остального кода программы.

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

Какое решение?

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

Реализация этого метода в разных классах очень схожа. Метод создаёт новый объект текущего класса и копирует в него значения всех полей собственного объекта. Так получится скопировать даже приватные поля, так как большинство языков программирования разрешает доступ к приватным полям любого объекта текущего класса. Объект, который копируют, называется прототипом, отсюда и название шаблона. Когда объекты программы содержат сотни полей и тысячи возможных конфигураций, прототипы могут служить своеобразной альтернативой созданию подклассов. Шаблон прототип должен копировать объекты любой сложности без привязки к их конкретным классам.

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

Диаграмма классов

Prototype Class DiagramPrototype Class Diagram

На диаграмме видим Интерфейс прототипов, который описывает операции клонирования. В большинстве случаев - это единственный метод clone. Конкретный прототип реализует логику клонирования самого себя. Тут важно не допускать ошибку клонирования. Например, клонирование связанных объектов, распутывание рекурсивных зависимостей и прочее. Клиент создаёт копию объекта, обращаясь к нему через общий интерфейс прототипов. Все просто.

Как реализовать?

Подробно пошаговую реализацию данного шаблона, а также других шаблонов проектирования на языке PHP можно посмотреть тут. Наша задача реализовать шаблон Prototype на языке Golang.

Рассмотрим пример реализации рубрикатора каталога продуктов в интернет-магазине с большим количеством категорий товаров. В рубрикаторе есть разделы верхнего уровня, в которых содержатся конечные рубрики и разделы второго уровня. Разделы второго уровня также могут содержать рубрик и разделы третьего уровня и т.д. То есть это древовидная структура объектов, у них есть стволы и листья - конечные элементы дерева. Каждая рубрика и раздел имеет свой набор свойств, относительно товаров раздела. Например, рубашки - это цвет, размер, бренд и т.д.

В какой-то момент потребовалась панель администрирования рубрикатора, чтобы копировать разделы в раздел и рубрики со всеми свойствами товаров. Например, в разделе одежды нужно быстро уметь клонировать рубрики мужские, женские, детские шорты и т.д.

Каждую рубрика, как конечный элемент рубрикатора, может быть представлен интерфейсом prototype, который объявляет функцию clone. За основу конкретных прототипов рубрики и раздела мы берем тип struct, которые реализуют функции show и clone интерфейса prototype.

Итак, реализуем интерфейс прототипа. Далее мы реализуем конкретный прототип directory, который реализует интерфейс prototype представляет раздел рубрикатора. И конкретный прототип для рубрики. Обе структуру реализуют две функции show, которая отвечает за отображение конкретного контента ноды и clone для копирования текущего объекта. Функция clone в качестве единственного параметра принимает аргумент, ссылающийся на тип указателя на структуру конкретного прототипа - это либо рубрика, либо директория. И возвращает указатель на поле структуры, добавляя к наименованию поля _clone.

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

Open directory 2  Directory 2    Directory 1        category 1    category 2    category 3Clone and open directory 2  Directory 2_clone    Directory 1_clone        category 1_clone    category 2_clone    category 3_clone

Когда применять?

  1. У вас много объектов с множеством свойств и вам нужно создавать их клоны быстро и эффективно. Шаблон предлагает использовать набор прототипов, вместо создания подклассов для описания популярных конфигураций объектов. Вместо порождения объектов из подклассов, вы копируете существующие объекты-прототипы, в которых уже настроено состояние. Тем самым избегая роста количества классов в программе и уменьшая её сложность.

  2. Код не должен зависеть от классов копируемых объектов. Если ваш код работает с объектами, переданными через общий интерфейс - вы не можете привязаться к их классам, даже если бы хотели, поскольку их конкретные классы неизвестны. Прототип предоставляет клиенту общий интерфейс для работы со всеми прототипами. Клиенту не нужно зависеть от классов копируемых объектов, а только от интерфейса клонирования.

Итог

Друзья, шаблон Prototype предлагает:

  • Удобную концепцию для создания копий объектов.

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

  • В объектных языках позволяет избежать наследования создателя объекта в клиентском приложении, как это делает паттерн abstract factory, например.

Кстати, друзья, вот тут можно посмотреть результаты опроса читателей хабра. 63% опрошенных считают, что применение шаблонов проектирования в Golang - это зло. Связано, скорее всего, с тем, что язык Golang процедурный и ему чужды подходы объектно-ориентированных языков. Но рассматривать реализации и применение шаблонов стоит, так как это позволяет больше их понимать и периодически применять для решения тех или иных задач. Каждый подход требует, конечно, дискуссии и разумного применения.

Друзья, рад был поделиться темой, Алекс. На английском статью можно найти тут.
Удачи!

Подробнее..

Перевод Как использовать GraphQL Federation для инкрементальной миграции с монолита (Python) на микросервисы (Go)

26.05.2021 16:17:30 | Автор: admin
Или как поменять фундамент старого дома, чтобы он не обвалился



Лет 10 назад мы выбрали 2-ю версию Python для разработки нашей обучающей платформы с монолитной архитектурой. Но с тех пор индустрия существенно изменилась. Python 2 был официально похоронен 1 января 2020 года. В предыдущей статье мы объясняли, почему решили отказаться от миграции на Python 3.

Каждый месяц нашей платформой пользуются миллионы людей.

Мы пошли на определённый риск, когда решили переписать наш бэкенд на Go и изменить архитектуру.

Язык Go мы выбрали по нескольким причинам:

  1. Высокая скорость компиляции.
  2. Экономия оперативной памяти.
  3. Достаточно широкий выбор IDE с поддержкой Go.

Но мы применили подход, который позволил минимизировать риск.

GraphQL Federation

Мы решили построить нашу новую архитектуру вокруг GraphQL Apollo Federation. GraphQL был создан разработчиками Facebook как альтернатива REST API. Федерация это построение единого шлюза для нескольких сервисов. Каждый сервис может иметь свою GraphQL-схему. Общий шлюз объединяет их схемы, генерирует единое API и позволяет выполнять запросы для нескольких сервисов одновременно.

Прежде чем, пойдём дальше, хотелось бы особо отметить следующее:

  1. В отличие от REST API, у каждого GraphQL-сервера есть собственная типизированная схема данных. Она позволяет получить любые комбинации именно тех данных с произвольными полями, которые вам нужны.

  2. Шлюз REST API позволяет отправить запрос только одному бэкенд-сервису; шлюз GraphQL генерирует план запросов для произвольного количества бэкенд-сервисов и позволяет вернуть выборки из них в одном общем ответе.

Итак, включив шлюз GraphQL в нашу систему, получим примерно такую картину:


URL картинки: https://lh6.googleusercontent.com/6GBj9z5WVnQnhqI19oNTRncw0LYDJM4U7FpWeGxVMaZlP46IAIcKfYZKTtHcl-bDFomedAoxSa9pFo6pdhL2daxyWNX2ZKVQIgqIIBWHxnXEouzcQhO9_mdf1tODwtti5OEOOFeb

Шлюз (он же сервис graphql-gateway) отвечает за создание плана запросов и отправки GraphQL-запросов другим нашим сервисам не только монолиту. Наши сервисы, написанные на Go, имеют свои собственные GraphQL-схемы. Для формирования ответов на запросы мы используем gqlgen (это GraphQL-библиотека для Go).

Так как GraphQL Federation предоставляет общую GraphQL-схему, а шлюз объединяет все отдельные схемы сервисов в одну, наш монолит будет взаимодействовать с ним так же, как и любой другой сервис. Это принципиальный момент.

Далее пойдёт речь о том, как мы кастомизировали сервер Apollo GraphQL, чтобы безопасно перелезть с нашего монолита (Python) на микросервисную архитектуру (Go).

Side-by-side тестирование


GraphQL мыслит наборами объектов и полей определённых типов. Код, который знает, что делать с входящим запросом, как и какие данные извлечь из полей, называется распознавателем (resolver).

Рассмотрим процесс миграции на примере типа данных для assignments:

123 type Assignment {createdDate: Time.}

Понятно, что в реальности у нас гораздо больше полей, но для каждого поля всё будет выглядеть аналогично.

Допустим, мы хотим, чтобы это поле из монолита было представлено и в нашем новом сервисе, написанном на Go. Как мы можем быть уверены, что новый сервис по запросу вернёт те же данные, что и монолит? Для этого используем подход, аналогичный библиотеке Scientist: запрашиваем данные и у монолита, и у нового сервиса, но затем сравниваем результаты и возвращаем только один из них.

Шаг 1: Режим manual


Когда пользователь запрашивает значение поля createdDate, наш GraphQL-шлюз обращается сначала к монолиту (который, напоминаю, написан на Python).


На первом шаге нам нужно обеспечить возможность добавления поля в новый сервис assignments, уже написанный на Go. В файле с расширением .graphql должен лежать следующий код распознавателя (resolver):

12345 extend type Assignment key(fields: id) {id: ID! externalcreatedDate: Time @migrate(from: python, state: manual)}

Здесь мы используем Федерацию, чтобы сказать, что сервис добавляет поле createdDate к типу Assignment. Доступ к полю происходит по id. Мы также добавляем секретный ингредиент директиву migrate. Мы написали код, который понимает эти директивы и создаёт несколько схем, которые GraphQL-шлюз будет использовать при принятии решения о маршрутизации запроса.

В режиме manual запрос будет адресован только коду монолита. Мы должны предусмотреть эту возможность при разработке нового сервиса. Чтобы получить значение поля createdDate, мы по-прежнему можем обращаться к монолиту напрямую (в режиме primary), а можем запрашивать у GraphQL-шлюза схему в режиме manual. Оба варианта должны работать.

Шаг 2: Режим side-by-side


После того, как мы написали код распознавателя (resolver) для поля createdDate, мы переключаем его в режим side-by-side:

12345 extend type Assignment key(fields: id) {id: ID! externalcreatedDate: Time @migrate(from: python, state: side-by-side)}

И вот теперь шлюз будет обращаться и к монолиту (Python), и к новому сервису (Go). Он будет сравнивать результаты, регистрировать случаи, в которых есть различия, и возвращать пользователю результат, полученный от монолита.

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

В процессе тестирования мы получаем вот такие отчёты.


Эту картинку при вёрстке попытайся увеличить как-то без сильной потери качества.

В них акцент сделан на случаи, когда в работе монолита и нового сервиса обнаруживаются расхождения.

Поначалу мы часто сталкивались с такими случаями. Со временем мы научились выявлять такого рода проблемы, оценивать их на критичность и при необходимости устранять.

При работе с нашими dev-серверами мы используем инструменты, которые выделяют различия цветом. Так легче анализировать проблемы и тестировать их решения.

А что по мутациям?


Возможно, у вас возник вопрос: если мы запускаем одинаковую логику и в Python, и в Go, что произойдет с кодом, который изменяет данные, а не просто запрашивает их? В терминах GraphQL это называется мутациями (mutation).

Наши side-by-side тесты не учитывают мутации. Мы рассмотрели некоторые подходы, позволяющие это сделать они оказались более сложными, чем мы думали. Но мы разработали подход, который помогает решить саму проблему мутаций.

Шаг 2.5: Режим сanary


Если у нас есть поле или мутация, которые успешно дожили до стадии продакшна, мы включаем режим canary (канареечный деплой).

12345 extend type Assignment key(fields: id) {id: ID! externalcreatedDate: Time @migrate(from: python, state: canary)}

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

Мы используем только одну канареечную схему за раз. На практике не так много полей и мутаций одновременно находятся в канареечном режиме. Так что, я думаю, проблем не будет и дальше. Это хороший компромисс, потому что схема довольно велика (более 5000 полей), а экземпляры шлюза должны хранить в памяти три схемы primary, manual и canary.

Шаг 3: Режим migrated


На этом шаге поле createdDate должно перейти в режим migrated:

12345 extend type Assignment key(fields: id) {id: ID! externalcreatedDate: Time @migrate(from: python, state: migrated)}

В этом режиме GraphQL-шлюз отправляет запросы только новому сервису, написанному на Go. Но в любой момент мы можем посмотреть, как обработает то же запрос монолит. Так намного легче делать деплой и откатывать изменения, если что-то пойдёт не так.

Шаг 4: Завершение миграции


После успешного деплоя нам больше не нужен код монолита для этого поля, и мы удаляем из кода распознавателя (resolver) директиву @migrate:

12345 extend type Assignment key(fields: id) {id: ID! externalcreatedDate: Time}

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

Вот такая она инкрементальная миграция!

И как далеко шагнули мы?


Мы завершили работу над нашей инфраструктурой side-by-side тестирования только в этом году. Это позволило нам безопасно, медленно, но верно переписать кучу кода на Go. В течение года мы поддерживали высокую доступность платформы на фоне роста объёма трафика в нашей системе. На момент написания этой статьи ~ 40% наших полей GraphQL вынесены в сервисы Go. Так что, описанный нами подход хорошо зарекомендовал себя в процессе миграции.

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

P.S. Стив Коффман делал доклад на эту тему (на Google Open Source Live). Вы можете посмотреть запись этого выступления на YouTube (или просто глянуть презентацию).



Облачные серверы от Маклауд быстрые и безопасные.

Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!

Подробнее..

Дженерики в языке Go

02.06.2021 14:09:29 | Автор: admin
func Map[F, T any](s []F, f func(F) T) []T {    r := make([]T, len(s))    for i, v := range s {        r[i] = f(v)    }    return r}

Как вы уже наверняка знаете, proposal по дженерикам в Golang принят (официально это называется type parameters) и будет имплементирован в go 1.18. Бета будет доступна уже в конце этого года. А это значит, что пора разобраться, на чём в итоге остановились разработчики языка ведь черновик type parameters постоянно менялся в течение последних лет.


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


Самостоятельно поиграться с дженериками можно здесь


Итак, поехали.


Зачем нужны дженерики в Go?


Несколько лет назад у нас в Каруне возникла необходимость перевести несколько сервисов с PHP на GO. Как сейчас помню, в первой же программе потребовалось проверить, существует ли строка в некотором слайсе строк. Беглый гуглёж показал, что встроенной функции, аналогичной пхпшной in_array() в языке нет. Поэтому пришлось написать свою, что-то типа такого:


func stringExistsInSlice(val string, values []string) bool {    for _, v := range values {        if val == v {            return true        }    }    return false}

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


func existsInSlice(val int, values []int) bool {    for _, v := range values {        if val == v {            return true        }    }    return false}

Написать универсальную функцию под все типы задача не очень простая. Можно использовать reflect и interface{}, как в примере на stackoverflow, но это, понятное дело, выглядит не очень и подвержено ошибкам, не проверяемым в момент компиляции. Или же можно использовать кодогенерацию, что тоже в общем-то так себе, так как это лишний шаг при билде.


Забегая вперёд, в go 1.18 это будет решаться так:


func existsInSlice[T comparable](val T, values []T) bool {    for _, v := range values {        if val == v {            return true        }    }    return false}

Нужно ли усложнять язык дженериками?


Вопрос дискуссионный. Мнения разделились.


Как известно, язык Go изначально был заточен под максимальную простоту, и обобщение типов может усложнить читабельность кода. Многие противопоставляют Go языку Java, традиционно наполненному обобщениями различного рода, и дженерики это как первый шаг в эту сторону. Теряется этакая гошная "дубовость" (в хорошем смысле), прямолинейность. Тем более, что в обычном продуктовом коде люди годами обходятся и без этого. Порой проще скопипастить немного кода и не париться. Как говорили в одном докладе, копипаста это "хорошее медитативное занятие".


С другой стороны, если надо написать универсальную библиотеку для каких-то универсальных целей, то придётся использовать interface{} или кодогенерацию, а это тоже в общем-то читабельности и надёжности не добавляет. Также необходимо отметить, что разработчики языка сделали всё возможное, чтобы дженерики выглядели и использовались как можно проще. Намного проще, чем в других языках.


Согласно результатам опроса 88% респондентов назвали отсутствие дженериков критической проблемой. 18% опрошенных сказали, что не используют Go именно из-за отсутствия этой функциональности (цитата: "18% of respondents are prevented from using Go because of a lack of generics").


Синтаксис функции с type parameters


Вот простейший пример. Функция, построчно печатающая элементы слайса любого типа.


func PrintSlice[T any](s []T) {    for _, v := range s {        fmt.Println(v)    }}

т.е. после имени функции в квадратных скобках описан некий идентификатор T (который дальше будет использован там, где вы бы раньше использовали обычный тип) и констрейнт (ограничение) для этого типа (констрейнт any означает, что в качестве T можно передать любой тип). Таких идентификаторов может быть несколько (через запятую); констрейнт для них указывать обязательно, это будет подробнее описано ниже.


А вот так происходит вызов такой функции, уже с конкретным типом string:


greatings := []string{"Hello", "world"};PrintSlice[string](greatings)

Т.е. синтаксически по сути мы передаём в функцию тип как обычный аргумент (параметр). Просто такие "аргументы" передаются и описываются в сигнатуре в отдельных квадратных скобках вместо круглых. Поэтому функциональность так и называется: type parameters.


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


greatings := []string{"Hello", "world"};PrintSlice(greatings)

Констрейнты (ограничения типов)


У каждого параметра-типа обязательно указывается ограничение типа.


func [T MyConstraint] (...

, где MyConstraint это интерфейс, который описывает, каким может быть тип. Этот интерфейс может быть обычным go-интерфейсом, описывающим требуемые методы.


type MyConstraint interface {    String() string}

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


type MyConstraint interface {    type int, int8, int16, int32, int64}

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


Есть встроенные в язык констрейнты, например any (синоним interface{}) и comparable (ограничивающий типы, для которых определены операторы сравнения).


Также в стандартную библиотеку планируется добавить пакет constraints, где будут добавлены различные полезняшки. Например, constraints.Number (под это подходят любые типы а ля int, float32 и т.д. )


Типы с обобщениями


Помимо функций подобным образом можно работать и с описанием типа.


Например, если вы пишете функции для умножения и сложения векторов, вам захочется иметь универсальный тип Vector


type Vector[T constraints.Number] []T

При использовании такого типа нужно указать в квадратных скобках его конкретный уже тип:


var myVec Vector[int]

Вот более-менее полный пример функции сложения векторов


type Number interface {    type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64}type Vector[T Number] []Tfunc AddVector[T Number](vec1 Vector[T], vec2 Vector[T]) Vector[T] {    var result Vector[T]    for i := range vec1 {        result = append(result, vec1[i]+vec2[i])    }    return result}func main() {    v1 := Vector[int]{1, 2, 3}    v2 := Vector[int]{3, 4, 5}    result := AddVector(v1, v2)    fmt.Println(result)}

поиграться с примером можно здесь: https://go2goplay.golang.org/p/n05eSb5uFXS


(в примере я не использовал встроенный интерфейс constraints.Number, так как на go2goplay.golang.org это почему-то не работает. Пришлось делать свой доморощенный interface Number)


Обратите внимание на то, что здесь нельзя использовать констрейнт any, так как операция сложения определена далеко не для всех типов, и вы получите соответствующую ошибку "operator + not defined".


Некоторые замечания по реализации


Пакеты


В стандартную библиотеку планируется добавить несколько пакетов, таких как slices, maps, chans и т.д., которые будут предоставлять универсальные функции для работы со слайсами, каналами и т.д.


Пакеты container/list, container/ring, sync и другие будут доработаны с точки зрения типобезопасности. Math получит новые универсальные функции для любых чисел (например Min и Max)


Эффективность


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


Цитата:


Generic functions, rather than generic types, can probably be compiled using an interface-based approach. That will optimize compile time, in that the function is only compiled once, but there will be some run time cost.

Generic types may most naturally be compiled multiple times for each set of type arguments. This will clearly carry a compile time cost, but there shouldn't be any run time cost. Compilers can also choose to implement generic types similarly to interface types, using special purpose methods to access each element that depends on a type parameter.

Отличие от java


Как известно, java удаляет информацию о дженериках после компиляции. В golang это не так.


Также, в Джаве реализована ковариантность и контрвариантность (List<? extends Number>, List<? super Number>), в Go всё намного проще: мы просто передаём тип как параметр, а тип ограничен интерфейсом.


Кстати, многие спрашивают, почему нельзя было сделать стандартные для многих языков (включая Java) скобки <>, а ввели новый вариант в виде квадратных скобок?


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


a, b = w < x, y > (z)

можно трактовать как


a, b = (w<x), (y<z)//илиa, b = w<x,y> (z)

Ещё примеры


Типобезопасная функция, объединяющая два канала в один


func Merge[T any](c1, c2 <-chan T) <-chan T {    r := make(chan T)    go func(c1, c2 <-chan T, r chan<- T) {        defer close(r)        for c1 != nil || c2 != nil {            select {            case v1, ok := <-c1:                if ok {                    r <- v1                } else {                    c1 = nil                }            case v2, ok := <-c2:                if ok {                    r <- v2                } else {                    c2 = nil                }            }        }    }(c1, c2, r)    return r}

Делаем свой Set на основе map


package setstype Set[T comparable] map[T]struct{}func Make[T comparable]() Set[T] {    return make(Set[T])}func (s Set[T]) Add(v T) {    s[v] = struct{}{}}func (s Set[T]) Delete(v T) {    delete(s, v)}func (s Set[T]) Contains(v T) bool {    _, ok := s[v]    return ok}func (s Set[T]) Len() int {    return len(s)}func (s Set[T]) Iterate(f func(T)) {    for v := range s {        f(v)    }}

Пример использования


s := sets.Make[int]()s.Add(1)if s.Contains(2) { panic("unexpected 2") }

Что дальше?


Дальше больше. Есть куча предложений, как улучшить дженерики: в частности автоматический вывод типов в различных ситуациях или упрощённая работа с zero value. Но это всё по сути ждёт, когда основная функциональность попадёт в язык.


В общем, поживём увидим. Осталось уже недолго.

Подробнее..

Чиним проблемы нагрузок в Go с помощью настройки пула HTTP-соединений

09.06.2021 20:16:20 | Автор: admin

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

Проблема скрывалась внутри нашего API Gateway. Это сервис, который реализует паттерн Фасад и предоставляет единственное торчащее наружу окно к микросервисам.

В упрощенном виде его работу можно представить так:

  1. Проверить аутентификацию и авторизацию с помощью HTTP-запроса в сервис аутентификации

  2. Спроксировать запрос в нужный сервис, который определяем по пути и методу запроса пользователя

Иллюстрация работы API GatewayИллюстрация работы API Gateway

Конец декабря время роста нагрузок и числа ошибок

Настал конец декабря. Вместе с ним к нам в поддержку стали приходить вот такие обращения:

При работе в ЛК возникают постоянные ошибки системные по 10-20 раз на дню и больше. Просьба исправить и наладить работу площадки.

Ошибки возникали на стороне API Gateway. Мы полезли в логи за подробностями и увидели ошибки, похожие на таймауты обращения к сервису аутентификацию:

{err_type: context.deadlineExceededError, err: context deadline exceeded}{err_type: *errors.errorString, err: context canceled}

Трейсы в Jaeger показали ровно такую же картину мы не дожидались ответа от сервиса аутентификации за 2 секунды. Поэтому между нами и разработчиками сервиса аутентификации произошёл примерно такой диалог:

- Ребята, кажется, вы таймаутите. Вот трейс, на котором видно, что мы не дождались от вас ответа за 2 секунды.

- Ничего подобного, у нас все норм мы за 200 миллисекунд отвечаем в 99% запросов. А вот вы по какой-то причине часто преждевременно обрываете соединение.

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

Скриншот с множеством ошибок Cancelled by clientСкриншот с множеством ошибок Cancelled by client

Итого, мы имеем:

  1. Используемый нами сервис аутентификации стабильно отрабатывает за 200 миллисекунд.

  2. Многие наши обращения к этому сервису таймаутят за 2 секунды.

Причина проблемы: дефолтные настройки в Go

В это время один из авторов нашего API Gateway отметил, что очень давно заметил аномалию: сервис открывает неожиданно много соединений к удаленным портам. При запуске команды из-под контейнера видно:

$ ss -natp state time-wait | awk '{print $4}' | sort -nr | uniq -c | sort -nr | head1053 10.20.49.117:801030 10.20.49.92:801016 10.20.49.91:801014 10.20.54.129:801013 10.20.53.213:801008 10.20.53.173:80969 10.20.53.172:80

Эта команда показывает количество TCP-сокетов в состоянии TIME_WAIT до разных удалённых портов. Если коротко, то состояние TIME_WAIT это де-факто закрытое клиентом соединение. Linux по возможности предотвращает повторное использование этих пар на протяжении 60 секунд, чтобы защититься от того, что старые пакеты помешают вновь установленному TCP-соединению.

Но для нас важно другое. Само существование TCP-соединения означает, что соединение установилось и закрылось. Если такая ситуация происходит массово, то мы имеем дело с накладными расходами на DNS-резолвинг и установку соединения. В результате этого время HTTP-запроса может увеличиваться. Избежать эту проблему помогают пулы соединении. В Go для этои цели используется абстракция http.Transport.

Здесь мы вплотную приближаемся к истокам проблемы. Мы для всех клиентских запросов использовали http.DefaultTransport. Он обладает следующими параметрами:

var DefaultTransport RoundTripper = &Transport{    Proxy: ProxyFromEnvironment,    DialContext: (&net.Dialer{        Timeout:   30 * time.Second,        KeepAlive: 30 * time.Second,    }).DialContext,    ForceAttemptHTTP2:     true,    MaxIdleConns:          100,    IdleConnTimeout:       90 * time.Second,    TLSHandshakeTimeout:   10 * time.Second,    ExpectContinueTimeout: 1 * time.Second,}

Среди перечисленных выше параметров к настройке пула соединений имеют отношения два:

  • MaxIdleConns число соединений, которое разрешается иметь в состоянии Idle (т.е. открытых TCP-соединений, которые в данный момент не используются);

  • IdleConnTimeout время, через которое закрываются такие неактивные соединения.

Однако в DefaultTransport совершенно не указан другой ключевой параметр MaxIdleConnsPerHost. Он отвечает за то, сколько неактивных TCP-соединений допускается устанавливать на один хост.

При этом если MaxIdleConnsPerHost не указан, тогда используется значение по умолчанию:

const DefaultMaxIdleConnsPerHost = 2

Поскольку мы использовали именно http.DefaultTransport для всех запросов, мы получили следующую проблему.

Представим, что нам одновременно понадобилось установить 10 соединений до сервиса аутентификации. Тогда хотя бы для 8 из них будут открыты и вскоре тут же закрыты TCP-соединения, из-за ограничения MaxIdleConnsPerHost. Если такая ситуация будет повторяться часто, у нас будет больше накладных расходов на один HTTP-запрос, поскольку для него понадобится новое соединение. Из-за этого вероятность таймаутов возрастает.

Решение: отдельный транспорт с особенными настройками

Чтобы решить проблему, мы сделали следующее:

  • Выделили отдельный транспорт под сервисы аутентификации и авторизации, чтобы транспорт под сервис аутентификации не вытеснял остальные сервисы.

  • Утилизировали выделенный пул на полную сделали так, чтобы значение MaxIdleConnsPerHost соответствовало значению MaxIdleConns:

func createOneHostTransport() *http.Transport {    result := http.DefaultTransport.(*http.Transport).Clone()    result.MaxIdleConnsPerHost = result.MaxIdleConns    return result}
График response time обращения к сервису аутентификацииГрафик response time обращения к сервису аутентификации

Тут видно значительное уменьшение 0.99-квантиля по времени обращения графиков (голубой цвет) с 2-3 секунд до менее 300 миллисекунд. Должен признать, даже после этого мы изредка видели таймауты при обращении к сервису аутентификации. Но теперь мы хотя бы видели эти же таймауты на графиках другого сервиса.

Но почему в Go такие настройки по умолчанию?

Возможно, у вас сейчас возник вопрос: зачем делать такие настройки, которые потом приходится исправлять? Неужели разработчики языка Go и библиотек к нему не подумали о том, как это будет использоваться на практике?

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

Если бы приложения могли говорить...

Чтобы лучше понять предназначение параметра MaxIdleConnsPerHost, представим, что компоненты системы научились говорить друг с другом. Тогда если бы мы выставили значение MaxIdleConnsPerHost равным MaxIdleConns, между нашим приложением и пулом соединений мог бы произойти такой диалог:

Приложение: http.Transport, привет! Нам тут сотня пользователей пришла одновременно и им всем вдруг срочно понадобилось посмотреть инфу о своём профиле. Установи, пожалуйста, сотню соединений к user-service

http.Transport: Конечно, вот получи сотню установленных соединений под запросы! Но я их не буду ещё полторы минуты закрывать, вдруг пригодятся .

Приложение: Всё сработало, спасибо! Только вот теперь им всем вдруг захотелось посмотреть информацию о товаре. Пожалуйста, установи соединение с product-service.

http.Transport: Да, не вопрос держи. Но только я их у себя в пуле соединений хранить не буду, поскольку он уже полностью забит соединениями до user-service, которые тебе больше не нужны.

Приложение: (_)

Разбираемся с непонятными таймаутами в Go: чеклист

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

  1. Проверьте метрики по скорости выполнения запросов к сторонним сервисам по HTTP. Если таких метрик нет, заведите пригодятся.

  2. Если видите расхождения в таймаутах клиента и сервера, проверьте количество соединений TIME_WAIT.

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

  4. Для хоста с неоправданно большим числом запросов к нему рассмотрите вариант заведения отдельного транспорта.

Что ещё почитать по теме

Подробнее..

Recovery mode Sypex Geo API на go

18.05.2021 12:12:57 | Автор: admin

Sypex Geo периодически обновляемая база данных для определения местоположения по IP-адресу. Распространяется по лицензии BSD, можно использовать в коммерческих продуктах. Подробно про нее в публикациях автора @zapimir: Sypex Geo быстрое определение города по IP, В Sypex Geo добавлена привязка к API ВКонтакте.

На сайте разработчиков, кроме собственного клиента, есть несколько реализаций API на разных ЯП. На PHP доступны бандл для Symphony 2, расширение для Laravel и Yii.

В рамках хардкорного обучения языку golang я написал к Sypex Geo 2.2 своё api.

На гите лежит версия alfa, брать с осторожностью. Все баги и косяки, конечно, на моей совести обычного PHP-шника, пишу, чтобы умерло через 30 сек (привет, Серёга C#), и в прод без проверки я б пока не тянул.

Как пользоваться

Ниже приведен пример готового http-сервера, который по запросу http://localhost:8080/ip=2.4.30.5 выдаст JSON-объект с городом, страной и регионом.

{    "city": {        "id": 2992166,        "name_ru": "Монпелье",        "name_en": "Montpellier",        "lat": 43.61092,        "lon": 3.87723,        "region_seek": 0    },    "country": {        "id": 74,        "iso": "FR",        "name_ru": "Франция",        "name_en": "France"    },    "region": {        "id": 3007670,        "iso": "FR-K",        "name_ru": "Лангедок-Руссильон",        "name_en": "Region Languedoc-Roussillon"    }}

Для работы надо скачать с сайта разрабов последнюю версию Sypex Geo City. Закиньте её в каталог с программой (или куда-то ещё, но тогда передайте серверу полный путь с флагом -d 'full/path/SxGeoCity.dat')

package mainimport ("encoding/json""flag""fmt""github.com/barsuk/sxgeo" // сама библиотека, о ней дальше"github.com/gin-gonic/gin""log""net/http""os")func main() {var ip stringvar endian boolvar setEndian intvar dbPath stringflag.StringVar(&ip, "ip", "", "ip address to convert")flag.IntVar(&setEndian, "se", 0, "set endianness")flag.BoolVar(&endian, "e", false, "check endianness of your system")flag.StringVar(&dbPath, "d", "./SxGeoCity.dat", "path to SxGeoCity.dat file")flag.Parse()  // можно передать флаг endian и проверить, как скомпилирована ваша система: little/big Endianif endian {sxgeo.DetectEndian()os.Exit(0)}  // можно установить правильный вариант архитектуры. В случае ошибки библиотека должна выдавать чушь, или я что-то забыл..if setEndian > 0 {sxgeo.SetEndian(sxgeo.BIG)fmt.Printf("host binary endian set to %s\n", sxgeo.Endian())}  // для работы надо считать файл SxGeoCity.dat в память.if _, err := sxgeo.ReadDBToMemory(dbPath); err != nil {log.Fatalf("error: cannot read database file: %v", err)}  // можно и не запускать сервер, а использовать прогу из командной строки  // я использовал этот вариант для проверки корректности очередной обновлённой базы от ребят из Sypex Geo.if len(ip) > 0 {city, err := sxgeo.GetCityFull(ip)if err != nil {fmt.Printf("error: %v", err)os.Exit(1)}enc, err := json.Marshal(city)if err != nil {fmt.Printf("error: %v", err)os.Exit(1)}fmt.Printf("%s\n", enc)os.Exit(0)}r := gin.New()r.GET("/", sxgeoHandler)erro := r.Run(fmt.Sprintf(":%d", 8080))if erro != nil {log.Fatalf("gin felt: %v", erro)}}// обработчик запроса с простой проверкойfunc sxgeoHandler(c *gin.Context) {// проверим на длину  ip := c.Query("ip")if len(ip) < 4 {c.IndentedJSON(http.StatusBadRequest, gin.H{"error": "give me an IP, please"})return}fmt.Printf("IP: %s\n", ip) // отложим в лог запрос  city, err := sxgeo.GetCityFull(ip) // вызываем библиотечный методif err != nil {c.IndentedJSON(http.StatusBadRequest, gin.H{"error": err.Error()})return}c.IndentedJSON(http.StatusAccepted, city)}

Ещё один пример готового кода для использования лежит в sxgeo_test.go.

Язык go кроссплатформенный, но в Windows я не проверял к сожалению, у меня её нет. Если кто попробует, пишите в комментариях.

Вдруг кто-нибудь совсем не знает го, но забрёл на геоопределение:

Инструкция для Ubuntu

  1. Установите го по инструкции: https://golang.org/doc/install

  2. Создайте каталог ~/go/src/sxgeo и файл main.go в нём.

  3. В файл main.go скопируйте код сервака выше.

  4. Из ~/go/src/sxgeo запускайте go run main.go -d 'path/to/SxGeo.dat'

  5. Пользуйтесь: http://localhost:8080/ip={IPv4 строкой типа 8.8.8.8}

Немного об устройстве sxgeo

Модуль sxgeo работает с файлом в формате SxGeo v2.2. Разработчики очень подробно специфицировали формат, за что им большое человеческое спасибо.

Формат базы данных предполагает зависимость от Byte Order системы: LittleEndian или BigEndian. Поэтому первое, что делаем устанавливаем или определяем его, иначе получим чушь на выходе распаковки.

Определитель этого параметра системы в sxgeo использует пакет unsafe и намекает на осторожность. Ещё большее опасение должен вызвать источник этого метода, Stackoverflow. Пока проблем не было, но вдруг что. Во избежание, переменная hbo (Host Byte Order) сделана глобальной, и порядок байтов можно определить другим, своим и безопасным способом.

Следующий этап распаковка БД в память. Родной php-клиент предоставляет возможность или считать всю базу в память, или распаковывать постепенно. В моих условиях памяти было достаточно, а свободного времени мало, поэтому всё в память. Так и быстрее работать будет.

За распаковку отвечает ReadDBToMemory. Функция делает то же, что и конструктор класса в родном клиенте считывает SxGeo.dat и разбивает бинарную запись в структуры языка: нескольких слайсов байт с городами, регионами, собственно IP, плюс метаданные.

Всё, что упаковано в базу для IP, выдаёт метод модуля GetCityFull. Внутри него две функции Seek(ip), определяющая необходимое смещение в БД, и parseFullCity, которая прочитает набор байт после смещения и превратит их в человекочитаемую структуру.

Функция Seek перевод get_num($ip) из php-клиента. Она отсеет мультикасты и loopback, проверит IP на IPv4-шность и проверит, что IP попадает в диапазон из метаданных базы. Потом вызовет searchDb этот монстрик и найдёт точное смещение нужной последовательности байтов.

Функция parseFullCity прочитает байты и распарсит их в один из двух наборов: либо страну и пустые регион с городом (мне там почему-то попадалась только одна такая страна), либо полный нормальный комплект. Самая ответственная работа лежит на функции unpack она из прочтённого слайса байтов в цикле вычленит всё, что предполагается в метаинформации. Тут-то и пригодится правильно определённый byte order вашей системы.

Что дальше

Скорее всего, в этом году библиотека дойдёт до какого-нибудь прода, но уже под другим именем. На гите всё останется в том виде, как есть сейчас. Единственное, что может добавиться погоняю по скорости с клиентом на PHP.

Сравнивая работу с программами на PHP и на go, отмечу, что в go мне удобнее и понятнее работается с бинарными данными. В PHP оно всё какое-то неродное, что ли.

Цель, которую себе ставил, достигнута определённый барьер сложности на go взят. Надеюсь, кому-то этот код тоже пригодится.

Подробнее..

Как мы весь интернет сканировали

20.06.2021 16:11:04 | Автор: admin

Всем привет! Меня зовут Александр и я пишу код для 2ip.ru. За добрую половину сервисов можно пинать меня, готов отбиваться. Cегодня я хочу немного рассказать про переделку одного нашего старого сервиса. Это конечно не "big data", но всё равно довольно большие объемы информации, поэтому думаю будет интересно.

Речь пойдет про Сайты на одном IP, который как вы уже догадались, позволяет узнать все домены зарегистрированные на одном IP. Довольно удобно посмотреть кто присосался к вашему серверу (да, есть и такие), ну или чужому (например shared хостинг).

Как это всегда работало? Мы ходили в Bing с большого пула адресов и парсили выдачу по специальному запросу. Да, решение так себе, но что было то было. Было, потому что бинг прикрутил гайки и мы решили всё это сделать по человечески.

Своя база

Что если взять и спарсить весь интернет? В общем то не проблема, но мы не Google и больших ресурсов для кролинга не имеем. Или имеем?

Есть сервер с 12 ядрами и 64 гигами памяти, а в арсенале MySQL, PHP, golang и куча всяких фреймворков. Очевидно, что благодаря горутинам можно достичь неплохих результатов. Golang быстр и требует минимум ресурсов. По базе вопросы, потянет ли это все обычный MySQL?

Пробуем.

Делаем прототип

Собирать все домены дело неблагодарное, поэтому мы купили базу доменов размером в 260 миллионов записей. Сервисов, которые предоставляют свои услуги довольно много и стоит это копейки.

Итак на моем диске CSV файл размером 5 ГБ, дело за малым, написать масс ресолвер, который будет читать строку за строкой, а на выход в STDOUT, отдавать пару "домен - IP адрес"

Вопрос только в производительности, нужно делать это очень и очень быстро, мы не можем ждать результата месяц.

Несколько часов работы и мой демон на гоу готов. Мэйн получился примерно такой:

func main() {    file, err := os.Open("domains.txt")    if err != nil {        log.Fatal(err)    }    defer file.Close()    maxGoroutines := 500    guard := make(chan struct{}, maxGoroutines)    scanner := bufio.NewScanner(file)    for scanner.Scan() {        guard <- struct{}{}        host := scanner.Text()        go func(host string) {            resolve(host)            <-guard        }(host)    }    if err := scanner.Err(); err != nil {        log.Fatal(err)    }}

Суть в том, что мы можем набрать пул в 500 горутинов и непрерывно обрабатывать большое количество данных в единицу времени, подгружая равномерно все 12 ядер сервера.

Функция resolve опущена, но кратко это обычный ресолвер IP с выдачей результата в STDOUT. Обращаемся к DNS, получаем A записи, выдаем результат.

DNS

Прогуглив немного я понял, что большие DNS особо не лимитируют количество запросов с одного IP, но кто знает. Поэтому было принято решение поднять undbound из Docker.

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

Второй вариант Google DNS, тот который четыре восьмерки, оказался гораздо быстрее. У меня были опасения по лимитам в 500 запросов в секунду но по факту их нет.

Тестируем в localhost и на проде

Нельзя сказать что на тестовом ноутбуке граббер работал быстро. 500 горутинов машина не потянула, процесс падал через несколько секунд. Зато все кардинально поменялось на боевом сервере.

1000 горутинов упали на 12 ядрах, а вот 500 практически не грузили проц и работали стабильно. Мощность получилась на уровне ~2000 доменов в секунду.

Это приемлемо, при таком раскладе всю базу можно спарсить за пару суток. На практике все оказалось немного хуже, очень сильно тупит TLD .bar, о которой вероятно нормальный человек и не слышал.

В конечном счёте я оставил процесс в tmux и через трое суток получил CSV размером 10 Гб. Идём дальше.

Ура! Переходим к следующему шагу.

База данных

Я создал таблицу domain_ip, в которой всего два столбца домен и IP. Оба не уникальны, на один домен может приходиться несколько IP адресов.

IP - это обычный BIGINT domain - VARCHAR 255

Индексы

Очевидно, что выборка из 260 млн записей это довольно большая работа. Поэтому без индексов не обойтись, поиск у нас по IP адресу, значит его и индексируем.

20 минут импорта на тестовой машине и я понял что это фиаско, выборка работает медленно несмотря на индексы. 260 млн записей это очень много. Переходим к плану Б.

Партиципирование

Это метод разделения больших таблиц на мелкие и в дальнейшем уже обращение по нужному адресу сразу к конкретной таблице.

Я разделил весь пул IP адресов на 20 таблиц с шагом 200 млн. Получилось примерно так:

ALTER TABLE domain_ip PARTITION BY RANGE COLUMNS (ip)  (    PARTITION p0 VALUES LESS THAN (200000000),    PARTITION p1 VALUES LESS THAN (400000000),    PARTITION p2 VALUES LESS THAN (600000000),    PARTITION p3 VALUES LESS THAN (800000000),    PARTITION p4 VALUES LESS THAN (1000000000),    PARTITION p5 VALUES LESS THAN (1200000000),    PARTITION p6 VALUES LESS THAN (1400000000),    PARTITION p7 VALUES LESS THAN (1600000000),    PARTITION p8 VALUES LESS THAN (1800000000),    PARTITION p9 VALUES LESS THAN (2000000000),    PARTITION p10 VALUES LESS THAN (2200000000),    PARTITION p11 VALUES LESS THAN (2400000000),    PARTITION p12 VALUES LESS THAN (2600000000),    PARTITION p13 VALUES LESS THAN (2800000000),    PARTITION p14 VALUES LESS THAN (3000000000),    PARTITION p15 VALUES LESS THAN (3200000000),    PARTITION p16 VALUES LESS THAN (3400000000),    PARTITION p17 VALUES LESS THAN (3600000000),    PARTITION p18 VALUES LESS THAN (3800000000),    PARTITION p19 VALUES LESS THAN (4000000000),    PARTITION p20 VALUES LESS THAN (MAXVALUE) );

И как вы поняли это сработало, иначе зачем эта статья? :)

Импорт

Кто работал с MySQL знает, что вливать большие дампы данных это довольно долгая операция. За долгие годы работы я не нашел ничего лучше, чем импорт данных из CSV. Выглядит это примерно так:

LOAD DATA INFILE '/tmp/domains.csv' IGNORE INTO TABLE domain_ipFIELDS TERMINATED BY ',' LINES TERMINATED BY '\n'

Машина переваривает CSV размером ~10 Гб за 30 минут.

Финал

Как результат получился вот такой милый сервис. Выборка из ~300 миллионов записей происходит мгновенно на довольно скромном по нынешним меркам сервере. Оперативной памяти нужно под это всё порядка 8 Гб.

Теперь можно узнать например, что к IP 8.8.8.8 человечество прицепило 8194 домена, ну или придумайте сами ... ;-)

Спасибо за внимание.

Подробнее..

Вызов кода Go из Dart с использованием cgo и Dart FFI на простом примере

09.06.2021 16:12:52 | Автор: admin

Ключевой мотивацией для написания данной статьи является факт сильного недостатка информации (особенно в русскоязычном сообществе) по использованию cgo и Dart FFI для вызова Go кода из языка Dart.

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

В случае если можно можно избежать экспорта go кода в Dart (например экспортировать готовую c библиотеку), то лучше воспользоваться такой возможностью и не использовать cgo. Однако, могут возникать случаи, когда перегонка go в dart кода является оптимальным решением (например вы уже знакомы с Go и Dart, и не хотите писать код на C, в таком случае есть смысл задуматься об использованием cgo и Dart FFI).

В данной статье на простом примере будет показано как можно вызвать код Go из языка Dart (например в приложениях на Flutter).

Что должно быть установлено:

  • Go

  • Dart

  • Текстовый редактор/IDE (я буду использовать VSCode, так как это самая популярная среда среди Dart и Go сообщества, так же будут установлены специальные плагины для поддержки языков Go и Flutter)

Шаг 1 - Создаем пустое консольное приложение на Dart

Вызываем Command Palette клавишей F1 и создаем новый проект на Dart, выбираем опцию Console Application (данный формат использован для примера, далее код на cgo можно будет использовать в том числе из Flutter проектов или других форматов приложений на Dart).

Назвать приложение можно в целом как угодно, я выбрал название cgo_dartffi_helloworld, исключительно для тестового примера. (Нам потребуется именно директория с проектом на Dart, так как мы будем добавлять ffi в pubspec.yaml файл).

Прожигаем кнопку создать и переходим в директорию со новоиспеченным проектом.

Шаг 2 - Добавляем ffi в yaml файл

Далее нам необходимо добавить ffi в yaml файл для возможности использования go кода из dart.

name: cgo_dartffi_helloworlddescription: A sample command-line application.version: 1.0.0environment:  sdk: '>=2.12.0 <3.0.0'dependencies:  path: ^1.8.0  ffi: ^0.1.3dev_dependencies:  pedantic: ^1.10.0  test: ^1.16.0

Шаг 3 - Создаем .go файл содержащий экспортируемую функцию

Далее необходимо создать файл на go, (в например в руте директории с проектом, например lib.go) который будет содержать функцию для экспорта в Dart. В данном примере эта функция - HelloFromGo().

// filename: lib.gopackage mainimport "C"//export HelloFromGofunc HelloFromGo() *C.char {message := "Hello to dart lang from go"return C.CString(message)}func main() {}

Стоит быть крайне аккуратными при написании кода cgo так как большая часть инструментов, включая сборщик мусора перестают работать. В cgo комментарии имеют значение (да, это странно), именно с помощью комментариев можно обозначить функцию которую необходимо экспортировать (используя слово export). Более подробно данные нюансы описаны на официальной странице cgo https://golang.org/cmd/cgo/, ну а мы вернемся к практической стороне вопроса.

Шаг 4 - Собираем динамическую библиотеку из go файла

Далее необходимо открыть терминал и запустить там следующую команду:

go build -buildmode=c-shared -o lib.a lib.go

Данная команда создаст файл lib.a (который и представляет из себя динамическую c библиотеку). Даже для такого небольшого файлика время сборки заставляет ужаснуться (аж целых несколько секунд, в отличии от моментальных сборок на go, еще один из плюсов go, который теряется при использовании cgo).

Шаг 5 - Проверяем наличие необходимых файлов

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

Она должна содержать следующие файлы:

  • Измененный pubspec.yaml файл

  • lib.h, lib.a файлы созданные из файла lib.go

  • директорию bin с дефолтным файлом библиотеки dart (туда мы сейчас и отправимся)

Шаг 6 - Прописываем биндинги на cgo функцию в Dart коде

Далее идёт самая сложная (и потенциально вызывающая ошибки) часть процедуры. Наличие ошибок возможно при несоответствии имён (так как нет возможности их проверить), по этому на данном этапе необходимо быть особенно аккуратными.

  • 6.1 - удаляем всё содержимое файла bin/cgo_dartffi_helloworld.dart и начинаем там писать с чистого листа

  • 6.2 - импортируем необходимые библиотеки (для нас это ffi и utf8 для передачи текста)

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';
  • 6.3 - открываем динамическую библиотеку

final dylib = ffi.DynamicLibrary.open('lib.a');
  • 6.4 - привязываем нашу функции к функции в dart

typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();
  • 6.5 - создаем метод который проверит вызов нашей функции (обратите внимание, метод .toDartString переводит стринг из формата C в формат Dart):

void main() {  print(_finalFunction().toDartString());}

Таким образом мы создали функцию на go, которая передает string в язык Dart.

Далее при написании своих функций следует учитывать, что форматы данных в языках Go, C и Dart могут отличаться (и зачастую так происходит), что приводит к необходимости использовать различные конвертации на стороне go/dart кода, более подробно можно ознакомиться по следующим ссылкам:

Полный код на Dart:

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';final dylib = ffi.DynamicLibrary.open('lib.a');typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();void main() {  print(_finalFunction().toDartString());}

При необходимости передавать параметры в вызываемую функцию можно использовать поинтеры и объявить их в вызываемой функции, например:

typedef GetHash = Pointer<Utf8> Function(Pointer<Utf8> str);typedef GetHashFunc = Pointer<Utf8> Function(Pointer<Utf8> str);final GetHash _getHashGoFunction =    _lib.lookup<NativeFunction<GetHashFunc>>('GetHash').asFunction();

Главное помнить, что необходимо проверять форматы передаваемых данных.

Подробнее..
Категории: C , Go , Dart , Flutter , Golang , Cgo , Ffi

Перевод Turbolift инструмент для масштабного рефакторинга

08.05.2021 16:18:41 | Автор: admin

Системы Skyscanner сложно назвать маломасштабными. Наш сайт и приложение каждый месяц используются миллионами путешественников, мы обрабатываем умопомрачительные объёмы запросов, используя микросервисную архитектуру, которая сама по себе далеко не маленькая.В общей совокупности у нас задействовано несколько сотен микросервисов и микросайтов (веб-приложений, поддерживающих определённую часть нашего сайта), обслуживаемых сотнями экземпляров AWS Lambda и библиотек. Каждое из этих средств хранится в своём собственном репозитории GitHub, что даёт некоторые преимущества с точки зрения разделения задач, но имеет и свою цену: когда одно и то же изменение нужно выполнить во всех этих репозиториях, как это можно осуществить?


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

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

Нам необходимо иметь возможность выполнения достаточно сложных изменений одновременно в десятках и сотнях репозиториев.

Долгое время мы разрабатывали свою внутреннюю систему под названием Codelift. В первую очередь это была система пакетной обработки, которая в ночное время применяла написанный на Python сценарий изменения для каждого из сотен репозиториев, отправляя предложения на изменения кода в чужих репозиториях (PR-предложения) для всех таких изменений. Но, как оказалось, очень сложно написать такой сценарий, который бы надёжно отрабатывал со всеми репозиториями. Главным узким местом была потребность в квалифицированных специалистах, которые требовались для проверки этих сценариев изменений. И самим сценариям часто требовалось несколько раундов настройки, чтобы преодолеть неизбежные сбои. Система Codelift постепенно выводилась из эксплуатации, но потребность в ней оставалась.

Появление Turbolift

Система Turbolift это переосмысление процесса внесения массовых изменений.

  • Прежде, чтобы написать надёжно работающий сценарий внесения изменения в системе Codelift, инженерам приходилось создавать локальные копии (клоны) многих или даже всех задействованных репозиториев только для проверки работоспособности этого изменения. Но если инженеры в любом случае собираются создавать локальные копии репозиториев, почему не сделать это частью процесса?

  • Подготовка сценариев изменений на Python накладывала свои ограничения: иногда самым простым способом реализации изменения является просто вызов команды из оболочки или запуск более специализированного инструмента рефакторинга, такого как codemod или comby. Иногда предпочтителен вызов редактора или интегрированной среды разработки это будет хоть и тяжеловесным, но самым верным способом. А иногда самым простым вариантом выполнения будет автоматическое изменение, которое сработает для 95 % репозиториев с последующей ручной настройкой для нескольких репозиториев, где такая настройка потребуется.

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

  • Одной из тонких проблем с Codelift было то, что все его PR-предложения исходили от пользователя-бота: при этом от владельцев системы Codelift ожидалась тщательная проверка каждого изменения, и это становилось серьёзным узким местом. Мы поняли, что оптимальным будет создание предложений на изменения кода в чужих репозиториях инженером, который фактически несёт ответственность за их выполнение. В этом случае обеспечиваются прозрачность распределения прав, более простая обратная связь и отсутствие необходимости создавать целую команду посредников.

Система Turbolift автоматизирует наиболее утомительные этапы этого процесса: массовое распараллеливание альтернативных вариантов выполнения процесса, клонирование, создание PR-предложенийбез внесения каких-либо противоречий при выполнении самих фактических изменений. Инженеры могут непосредственно проверять, изменять и тестировать свои изменения, используя те инструменты, которые им требуются, что позволяет работать с гораздо большей степенью отдачи, чем просто отправить сценарий в систему пакетной обработки и ожидать результатов.

Создание клонов на машинах разработчиков имеет очевидные минусы: для этого требуется время и дисковое пространство. Но, на наш взгляд, снижение информационной нагрузки на инженеров нивелирует эти издержки.

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

Если вы начнёте работать с Turbolift, советуем вам уделить особое внимание потребностям инженеров, анализирующих предложения на изменения кода в чужих репозиториях, особенно, если создаётся много таких PR-предложений. В файле README для этого проекта содержится несколько рекомендаций, которые мы разработали внутрикорпоративно, чтобы помочь авторам изменений, удерживая их в рамках разумного.

Как инструмент Turbolift помог нам

  • При приближении момента, когда истечёт срок действия какого-либо внутреннего SSL-сертификата, наша команда сопровождения промышленной платформы использовала Turbolift для выполнения PR-предложений сотен репозиториев, в которых были ссылки на истекающий сертификат.

  • Turbolift применяется нашей командой веб-поддержки для стандартизации версий и тестирования библиотек на наших микросайтах.

  • Наша команда сопровождения промышленной платформы использовала Turbolift для исправления ошибки, которую когда-то допустили в шаблоне кода, а затем растиражировали по множествам репозиториев.

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

В целом за последние три месяца, используя Turbolift, мы отправили свыше 1200 внутренних предложений на изменения кода в чужих репозиториях. Каждый из этих случаев означает устранённую проблему или исправленную техническую недоработку, которые в противном случае превратились бы в создаваемые вручную PR-предложения. Мы надеемся, что инженеры в Skyscanner и других компаниях в полной мере ощутят преимущества от упрощения рабочего процесса при выполнении масштабных изменений.

Turbolift написан на Go компилируемом языке от Google, который вы за год освоите с нуля на курсе Backend-разработчик на Go от ключевых понятий в IT, основ Linux и до применения Go для DevOps. Мы используем модель фундаментального образования, поэтому вы получите не только практические навыки, но и крепкую теоретическую базу, научитесь мыслить по-новому и в этом вам помогут эксперты в своём деле и менторы, которые с удовольствием ответят на ваши вопросы и передадут вам свои знания.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Архитектура кода программного обеспечения декорируем стратегией. Рассказ в 10 эпизодах, основанный на реальных событиях

21.05.2021 08:10:50 | Автор: admin

Встречаются два эксперта-консультанта по конструированию программного обеспечения:
- Как написать сложное корпоративное приложение, поддерживать которое будет всегда легко и дешево.
- Могу рассказать...
- Рассказать и я могу! Написать-то как?..

Время чтения: 25 мин.

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

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

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

Введение в предметную область

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

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

В этой статье я хочу предложить технику написания программ, в основе которой лежит два паттерна проектирования ООП: декоратор и стратегия. Я уверен, что основная часть читающих статью наверняка не раз сталкивалась с этими паттернами (возможно, даже на практике). Но чтобы все чувствовали себя "в своей тарелке", обращусь к определениям из "Паттернов проектирования" Эриха Гаммы, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса (Банда четырех, Gang of Four, GoF):

  • Декоратор (Decorator, Wrapper) паттерн проектирования, позволяющий динамически добавлять объекту новые обязанности. Является гибкой альтернативой порождению подклассов с целью расширения функциональности.

  • Стратегия (Strategy, Policy) паттерн проектирования, который определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. Стратегия позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.

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

Декорирование стратегией, на мой взгляд, даёт великую пользу при поддержке приложений на очень большом жизненном цикле программного продукта. Компоненты в коде, написанные с применением данного подхода, соответствуют всем принципам дизайна SOLID из "Чистой архитектуры" Роберта Мартина. Каждый компонент, который мы напишем далее, будет отвечать только за одно действие; после написания нового компонента мы ни разу не модифицируем логику его методов, а лишь будем расширять ее в декорирующих компонентах; в силу паттерна "Декоратор" все расширяемые и расширяющие компоненты соответствуют одному контракту, следовательно их можно заменять друг другом; интерфейсы компонентов не содержат зависимостей, которые не используются; компоненты бизнес-логики ни в коей мере не зависят от деталей.

Я не раз сталкивался в обсуждениях с опытными разработчиками, которые говорят: "А вот всё, что связано с применением принципов SOLID, паттернов ООП на практике это миф!". Любезно обращаясь к скептически настроенным к применению теории разработки в реальных больших корпоративных проектах, хочу сказать: "А вот посмотрим!"

Предлагаю обозначить несколько условностей. Код приводить я буду на языке Golang. Конечно Go не самый лучший язык для демонстрации "фишек" ООП, но, во-первых, так мы покажем, что применение паттернов проектирования не должно страдать от выбора языка программирования, ибо язык это априори инструмент, а во-вторых, для меня данный язык на сей день ближе всего находится к нашим реальным корпоративным проектам, которые успешно работают в продакшне.

Также я хочу выделить очень важные моменты, которые в реальном коде обязательно бы имели место, но так как код в статье имеет демонстрационное назначение, здесь эти моменты будут опускаться, дабы не "перетягивать на себя" ценное внимание читателя:

  • Должная обработка ошибок. В коде мы ограничимся оборачиванием ошибок дополнительным сообщением с помощью пакета "github.com/pkg/errors".

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

  • Комментарии и документирование кода.

  • Всё, что связано, с конкурентным выполнением задач и синхронизацией.

  • Структура файлов и директорий проекта.

  • Стили, линтеры и статический анализ.

  • Покрытие кода тестами.

  • Сквозь методы компонентов рекомендуется с первых этапов разработки "тянуть" context.Context, даже если он в тот момент не будет использоваться. Для упрощения повествования в примерах далее контекст также использоваться не будет.

Перейдём же наконец от скучной теории к занимательной практике!

Пролог. Закладываем фундамент

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

Итак, начнём. Здесь мы с вами высококвалифицированные разработчики программных продуктов. К нам приходит наш первый заказчик от бизнеса и говорит что-то вроде: "Нам нужна функциональность обновления такой-то информации о пользователях нашей платформы". Мы обрабатываем требования, продумываем архитектуру и переходим к конструированию кода.

Первое, что нужно сделать определить интерфейс нашего первого компонента службы, которая будет представлять желаемый use-case SavePersonService. Но для этого нам нужно определить объекты нашей предметной области, а именно структуру данных, содержащую информацию о человеке PersonDetails. Создадим в корне проекта пакет app, далее создадим файл app/person.go, и оставим в нём нашу структуру:

// app/person.gotype PersonDetails struct {    Name string    Age  int}

Данный файл завершён, больше мы к нему в этой статье возвращаться не будем. Далее создаем файл app/save-person.go, и определяем в нём интерфейс нашего use-case:

// app/save-person.gotype SavePersonService interface {    SavePerson(id int, details PersonDetails) error}

Оставим сразу рядом с определением интерфейса его первую реализацию компонент noSavePersonService, который ничего не делает в теле интерфейсного метода:

// app/save-person.go// ... предыдущий код ...type noSavePersonService struct{}func (noSavePersonService) SavePerson(_ int, _ PersonDetails) error { return nil }

Поскольку объекты noSavePersonService не содержат состояния, можно гарантировать, что данный "класс" может иметь только один экземпляр. Напоминает паттерн проектирования Синглтон (Singleton ещё его называют Одиночка, но мне это название по ряду причин не нравится). Предоставим глобальную точку доступа к нему. В Golang легче всего это сделать, определив глобальную переменную:

/ app/save-person.go// ... предыдущий код ...var NoSavePersonService = noSavePersonService{}

Зачем мы написали ничего не делающий компонент? С первого взгляда он очень походит на заглушку. Это не совсем так. Далее поймём.

Эпизод 1. Будем знакомы, Декоратор Стратегией

Перейдём непосредственно к реализации бизнес-логики нашей задачи. Нам нужно в конечном счёте иметь хранилище, в котором содержатся данные о пользователях. С точки зрения выбора технологии мы сразу себе представляем, что будем использовать PostgreSQL, но правильно ли завязываться в коде нашей бизнес-логики на конкретную технологию. Вы правы конечно нет. Определить компонент нашего хранилища нам позволит паттерн Репозиторий (Repository). Создадим пакет с реализациями интерфейса нашего use-case save-person внутри app, и в нём создадим файл app/save-person/saving_into_repository.go реализации нашего use-case, которая обновляет данные в репозитории:

// app/save-person/saving_into_repository.gotype PersonRepository interface {    UpdatePerson(id int, details app.PersonDetails) error}type SavePersonIntoRepositoryService struct {    base app.SavePersonService    repo PersonRepository}func WithSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {    return SavePersonIntoRepositoryService{base: base, repo: repo}}func (s SavePersonIntoRepositoryService) SavePerson(id int, details app.PersonDetails) error {    err := s.base.SavePerson(id, details)    if err != nil {        return errors.Wrap(err, "save person in base in save person into repository service")    }    err = s.repo.UpdatePerson(id, details)    if err != nil {        return errors.Wrap(err, "update person in repo")    }    return nil}

В коде выше впервые появляется компонент, который выражает наш подход "Декорирование стратегией". Сам компонент представляет собой декоратор, реализующий интерфейс нашего use-case, который оборачивает любой компонент с таким же интерфейсом. В реализации метода изначально вызывается метод декорируемого объекта s.base; после этого происходит вызов стратегии обновления данных о человеке в хранилище s.repo. По сути, весь подход это конструирование компонентов-декораторов, которые содержат два объекта:

  1. Непосредственно декорируемый объект с таким же интерфейсом.

  2. Стратегия, логику которой мы добавляем в довесок к логике декорируемого объекта.

Структурная схема программы, собранной из декораторов стратегий может выглядеть примерно так:

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

Напомню, что бизнес-логика не должна содержать ненужные зависимости, зависимости от деталей и т.п. Другими словами, бизнес-логика должна быть "чистая, как слеза". Где тогда должны находиться зависимости от конкретных реализаций, зависимости от используемых технологий? Ответ в файле main.go. Следуя замечаниям Роберта Мартина, можно сделать умозаключение, что код компонентов файла, содержащего точку входа в программу, является самым "грязным" с точки зрения зависимостей от всего. Обозначим в main.go метод, который нам возвращает клиент к базе данных PostgreSQL. И собственно сборку объекта службы нашего use-case и вызов его метода на условных входных данных:

// main.gofunc NewPostgreSQLDatabaseClient(dsn string) savePerson.PersonRepository {    _ = dsn // TODO implement    panic("not implemented")}func run() error {    userService := savePerson.WithSavingPersonIntoRepository(        app.NoSavePersonService,        NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable"))    err := userService.SavePerson(5, app.PersonDetails{        Name: "Mary",        Age:  17,    })    if err != nil {        return errors.Wrap(err, "save user Mary")    }    return nil}

В коде выше мы можем заметить, что в качестве стратегии репозитория выступает обозначенный конкретный компонент клиента к PostgreSQL. В качестве же декорируемого объекта выступает наша "фиктивная" реализация use-case app.NoSavePersonService, которая по сути ничего не делает. Зачем она нужна? Она ничего полезного ведь не делает? Не легче ли просто вызвать метод клиента к базе данных? Спокойно, звёздный час этой реализации сейчас настанет.

Ссылка на полный код эпизода

Эпизод 2. Магия начинается!

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

// main.go// ... предыдущий код ...func NewMemoryCache() savePerson.PersonRepository {    // TODO implement    panic("not implemented")}// ... последующий код ...

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

// main.go// внутри run()userService := savePerson.WithSavingPersonIntoRepository(    savePerson.WithSavingPersonIntoRepository(        app.NoSavePersonService,        NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/users?sslmode=disable")),    NewMemoryCache(),)err := userService.SavePerson(5, app.PersonDetails{    Name: "Mary",    Age:  17,})if err != nil {    return errors.Wrap(err, "save user Mary")}

Всё, что мы тут делаем в итоге два раза декорируем наш "холостой" сервис обновлениями данных в двух репозиториях разного происхождения. Теперь мы можем добавлять обновление данных в новых репозиториях достаточно быстро и комфортно.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 3. Рефакторинг для здоровья

В предыдущем листинге кода создание сервиса выглядит достаточно громоздко. Нетрудно догадаться, применяя наш подход, мы продолжим и далее всё больше и больше оборачивать компонент, добавляя к логике новые стратегии. Поэтому мы, как опытные разработчики, замечаем эту потенциальную трудность и производим небольшой рефакторинг когда. Нам поможет паттерн Билдер (Builder опять же мне не очень нравится ещё одно его название Строитель). Это будет отдельный компонент, зона ответственности которого предоставить возможность сборки объекта службы нашего use-case. Файл app/save-person/builder.go:

// app/save-person/builder.gotype Builder struct {    service app.SavePersonService}func BuildIdleService() *Builder {    return &Builder{        service: app.NoSavePersonService,    }}func (b Builder) SavePerson(id int, details app.PersonDetails) error {    return b.service.SavePerson(id, details)}

Компонент Builder должен обязательно реализовывать интерфейс службы нашего use-case, так как именно он будет использоваться в конечном счёте. Поэтому мы добавляем метод SavePerson, который вызывает одноименный метод объекта в приватном поле service. Конструктор данного компонента называется BuildIdleService, потому что создаёт объект, который ничего не будет делать при вызове SavePerson (нетрудно заметить инициализацию поля service объектом app.NoSavePersonService). Зачем нам нужен этот бесполезный компонент? Чтобы получить всю истинную пользу, необходимо обогатить его другими методами. Эти методы будут принимать в параметрах стратегию и декорировать ею объект службы в поле service. Но вначале сделаем конструктор WithSavingPersonIntoRepository в app/save-person/saving_into_repository.go приватным, так как для создания службы мы теперь будем использовать только Builder:

// app/save-person/saving_into_repository.go// ... предыдущий код ...func withSavingPersonIntoRepository(base app.SavePersonService, repo PersonRepository) SavePersonIntoRepositoryService {    return SavePersonIntoRepositoryService{base: base, repo: repo}}// ... последующий код ...

Добавляем соответствующий метод для Builder:

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithSavingPersonIntoRepository(repo PersonRepository) *Builder {    b.service = withSavingPersonIntoRepository(b.service, repo)    return b}

И наконец производим рефакторинг в main.go:

// main.go// ... предыдущий код ...userService := savePerson.BuildIdleService().        WithSavingPersonIntoRepository(NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).        WithSavingPersonIntoRepository(NewMemoryCache())// ... последующий код ...

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 4. Больше заказчиков!

Через несколько дней успешной работы нашего кода в продакшне, к нам приходит другой заказчик от бизнеса и просит реализовать функциональность обновления информации о налогоплательщиках в отдельном хранилище. По неким причинам, обсуждение которых находится за пределами данной статьи, мы понимаем, что эту информацию лучше хранить в MongoDB. Клиент к базе добавляется в main.go:

// main.go// ... предыдущий код ...func NewMongoDBClient(dsn string) savePerson.PersonRepository {    _ = dsn // TODO implement    panic("not implemented")}// ... последующий код ...

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

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithSavingPersonIntoRepository(NewMemoryCache())err = taxpayerService.SavePerson(1326423, app.PersonDetails{    Name: "Jack",    Age:  37,})if err != nil {    return errors.Wrap(err, "save taxpayer Jack")}

Мы выполнили уже столько поставленных задач, имея небольшой фрагмент кода бизнес-логики. Заметьте, изменения преимущественно вносятся в файл main.go

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 5. Путь в никуда

Проходит ещё время. Заказчик 2 ставит нам такую задачу. Так как все налогоплательщики должны быть совершеннолетними, необходимо в бизнес-логику добавить функциональность проверки возраста человека перед сохранением в хранилище. С этого момента начинаются интересные вещи. Мы можем добавить эту валидацию в метод SavePersonIntoRepositoryService.SavePerson в файле app/save-person/saving_into_repository.go. Но тогда при нескольких декорированиях стратегией сохранения информации в репозиторий эта валидация будет вызываться столько раз, сколько производилось таких декораций. Хотя и все проверки помимо первой никак не влияют на результат напрямую, всё-таки не хочется лишний раз вызывать один и тот же метод.

Мы можем добавить валидацию в Builder.SavePerson. Но есть проблема: заказчику 1 не нужна проверка возраста при сохранении. Придётся добавить if и дополнительный флаг в параметры конструктора, который будет определять необходимость валидации:

// app/save-person/builder.gotype Builder struct {    service           app.SavePersonService    withAgeValidation bool}func BuildIdleService(withAgeValidation bool) *Builder {    return &Builder{        service:           app.NoSavePersonService,        withAgeValidation: withAgeValidation,    }}func (b Builder) SavePerson(id int, details app.PersonDetails) error {    if b.withAgeValidation && details.Age < 18 {        return errors.New("invalid age")    }    return b.service.SavePerson(id, details)}// ... последующий код ...

И тогда в main.go нужно вызывать конструкторы билдера с разными значениями флага withAgeValidation:

// main.go// ... предыдущий код ... userService := savePerson.BuildIdleService(false).// ... код ...taxpayerService := savePerson.BuildIdleService(true).// ... последующий код ...

Теперь код будет работать так, как это от него требуется. Но есть поверье, что если в бизнес-логике появляется if, то положено твердое начало прохождению всех кругов ада при дальнейшей поддержке, будьте уверены.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 6. Путь истины

В этом эпизоде мы постараемся решить поставленную задачу предыдущего эпизода более изящно. Изменения начнём вносить в код, полученный в результате эпизода 4.

Добавим новый компонент, который будет отвечать за валидацию при сохранении информации о людях:

// app/save-person/validating.gotype PersonValidator interface {    ValidatePerson(details app.PersonDetails) error}type PreValidatePersonService struct {    base      app.SavePersonService    validator PersonValidator}func withPreValidatingPerson(base app.SavePersonService, validator PersonValidator) PreValidatePersonService {    return PreValidatePersonService{base: base, validator: validator}}func (s PreValidatePersonService) SavePerson(id int, details app.PersonDetails) error {    err := s.validator.ValidatePerson(details)    if err != nil {        return errors.Wrap(err, "validate person")    }    err = s.base.SavePerson(id, details)    if err != nil {        return errors.Wrap(err, "save person in base in pre validate person service")    }    return nil}

Опять ничего нового. PreValidatePersonService это очередной декоратор стратегией валидации перед последующим вызовом декорируемого метода.

Добавим соответствующий метод в Builder:

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithPreValidatingPerson(validator PersonValidator) *Builder {    b.service = withPreValidatingPerson(b.service, validator)    return b}

Добавление каждого нового декоратора стратегией требует добавление нового метода в наш билдер.

Добавим реализацию валидатора, проверяющую возраст человека:

// main.go// ... предыдущий код ...type personAgeValidator struct{}func (personAgeValidator) ValidatePerson(details app.PersonDetails) error {    if details.Age < 18 {        return errors.New("invalid age")    }    return nil}var PersonAgeValidator = personAgeValidator{}// ... последующий код ...

Так как personAgeValidator не имеет состояния, можем сделать для компонента единую точку доступа PersonAgeValidator. Далее просто вызываем новый метод в main.go только для taxpayerService:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator)// ... последующий код ...

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 7. А ну-ка закрепим

Уверен, к данному эпизоду вы поняли смысл подхода "Декорирование стратегией". Чтобы закрепить, давайте добавим ещё один такой компонент. Представим, технический руководитель требует от нас покрыть метриками время выполнения сохранения данных в хранилище. Мы могли бы замерить это время, просто добавив пару строчек кода в SavePersonIntoRepositoryService. Но как бы не так! Мы же не изменяем уже работающий в продакшне код, а можем его только расширить. Давайте же так и сделаем. Добавим новый декоратор стратегией отправки метрики времени:

// app/save-person/sending_metric.gotype MetricSender interface {    SendDurationMetric(metricName string, d time.Duration)}type SendMetricService struct {    base         app.SavePersonService    metricSender MetricSender    metricName   string}func withMetricSending(base app.SavePersonService, metricSender MetricSender, metricName string) SendMetricService {    return SendMetricService{base: base, metricSender: metricSender, metricName: metricName}}func (s SendMetricService) SavePerson(id int, details app.PersonDetails) error {    startTime := time.Now()    err := s.base.SavePerson(id, details)    s.metricSender.SendDurationMetric(s.metricName, time.Since(startTime))    if err != nil {        return errors.Wrap(err, "save person in base in sending metric service")    }    return nil}

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

// app/save-person/builder.go// ... предыдущий код ...func (b *Builder) WithMetricSending(metricSender MetricSender, metricName string) *Builder {    b.service = withMetricSending(b.service, metricSender, metricName)    return b}

И наконец обозначаем в main.go функцию, возвращающую savePerson.MetricSender и добавляем вызов нового метода Builder в сборку наших сервисов:

// main.go// ... предыдущий код ...func MetricSender() savePerson.MetricSender {    // TODO implement    panic("not implemented")}// ... код ...userService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewPostgreSQLDatabaseClient("postgres://user:pass@127.0.0.1:5432/platform?sslmode=disable")).    WithMetricSending(MetricSender(), "save-into-postgresql-duration").    WithSavingPersonIntoRepository(NewMemoryCache())// ... код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator)// ... последующий код ...

Обратите внимание, что новые методы мы ставим в цепочку вызовов там, где мы хотим производить замер.

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 8. Результаты ясновидения

Проходит время. Заказчик 2 ставит новую задачу. Он желает знать, как долго выполняется сохранение данных о налогоплательщике, но с небольшой оговоркой: учитывать нужно всё, кроме валидации. Похоже на замер времени, который мы недавно реализовали для своих целей, не правда ли? Чтобы решить задачу, всё что нам требуется это добавить вызов метода для новой метрики в main.go:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithMetricSending(MetricSender(), "save-taxpayer-duration").    WithPreValidatingPerson(PersonAgeValidator)

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 9. Укрощение капризов

Мы вот только недавно произвели релиз последней задачи от заказчика 2, но он захотел изменить начальные требования. Такие изменения часто возникают на стороне заказчика, которые заставляют нас "перелопатить" весь код. Знакомо? На этот раз заказчик желает отказаться от оговорки из предыдущего эпизода и производить замер полного цикла сохранения данных о налогоплательщике вместе с валидацией. Если бы мы конструировали нашу бизнес-логику в виде сценария транзакции (transaction script), то это повлекло бы за собой непосредственное вмешательство в тело метода, copy-paste кода, что требует приложить силы, в том числе в процессе ревью, тестирования и т.п. В нашем же случае нам достаточно просто подвинуть вызов метода WithMetricSending в цепочке методов создания объекта службы в main.go:

// main.go// ... предыдущий код ...taxpayerService := savePerson.BuildIdleService().    WithSavingPersonIntoRepository(NewMongoDBClient("mongodb://user:pass@127.0.0.1:27017/tax_system")).    WithMetricSending(MetricSender(), "save-into-mongodb-duration").    WithSavingPersonIntoRepository(NewMemoryCache()).    WithPreValidatingPerson(PersonAgeValidator).    WithMetricSending(MetricSender(), "save-taxpayer-duration")

В коде выше мы поменяли местами второй WithMetricSending и WithPreValidatingPerson.

Задача от заказчика выглядит надуманной. Но напомню, что цель статьи не придумать качественные задачи заказчиков, а продемонстрировать пользу архитектуры кода при использовании подхода "Декорирование стратегией".

Ссылка на diff эпизода
Ссылка на полный код эпизода

Эпизод 10. Взгляд в будущее

Этот заключительный эпизод всего лишь подчеркивает потенциал дальнейших доработок логики данного кода. Что ещё может пожелать заказчик от бизнеса или с технической стороны? Вариантов более чем достаточно. Может потребоваться функциональность отправки асинхронных событий об изменении информации о человеке (полезно при ведении журнала аудита, коммуникации с другими сервисами и т.д.). Может понадобиться введение механизма гомогенных и даже гетерогенных транзакций. Возможно, потребуется добавить запрос данных к соседнему микросервису. По техническим соображениям возможно будет нужен предохранитель (circuit-breaker) для таких запросов к другим сервисам. Наверняка нужно будет добавлять механизм трассировки (tracing). И многое-многое другое.

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

Эпилог. Подводим итоги

Вышеописанный подход конструирования программного обеспечения представляет набор моих субъективных взглядов. Я пришёл к нему однажды, был приятно воодушевлён его пользой. Велика вероятность, что вы тоже используете такой подход, называя его как-то иначе. Возможно, вы к нему тоже приходили, но он вам не понравился. Ни в коем случае не хочу сказать, что данный подход является единственным истинным при разработке.

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

Но для больших корпоративных приложений наличие подобного подхода просто желательно-обязательно. Если продукт подразумевает длительную поддержку (обычно это условие присутствует всегда), то объектная модель приложения будет иметь значительное преимущество над незамысловатым "полотном" кода сценария транзакции. Я приведу далее график, в основе которого лежит график из "Шаблонов корпоративных приложений" Мартина Фаулера.

Что есть что на этом графике? Почему на осях нет чисел? Всё потому что график абстрактный. Он отражает качественный смысл содержимого, не количественный. По горизонтальной оси у нас время, прошедшее с момента начала разработки продукта. Или если желаете, количество добавлений новой функциональности в изначально разработанный продукт. Меру по вертикальной оси тоже можно выразить различными способами. Это может быть цена добавления новой строчки кода функционала в денежном эквиваленте; может быть время добавления новой функциональности; может быть количество потраченных нервных клеток разработчиком, ревьювером или тестировщиком. Красный график демонстрирует зависимость этих величин для подхода разработки, который называется сценарием транзакции (Transaction Script) последовательно следующие друг за другом инструкции. Синий график показывает эту зависимость для подхода модели предметной области (Domain Model).

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

Сложность внесения нового функционала при использовании модели предметной области, конечно, тоже растёт, но линейно. Это говорит о том, что на поздних стадиях разработки продукт, сделанный при использовании подходов модели предметной области, будет обходиться гораздо дешевле, чем проект, сделанный при использовании более простых подходов "в лоб".

Содержание статьи изложено на основе моего субъективного понимания. Любые замечания с удовольствием готов обсуждать в комментариях. Использовать "Декорирование стратегией" или нет личное решение каждого. Главное, я считаю, нужно помнить о том, что мы как разработчики должны в первую очередь уделять внимание не бизнесу, не пользователю, не выделенным машинным ресурсам, а нашему коллеге такому же разработчику, который через несколько лет будет добавлять в наш код новую функциональность.

Литература

  1. Макконнелл С. Совершенный код. Мастер-класс., 2020.

  2. Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования., 2020.

  3. Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения., 2020

  4. Фаулер, Мартин. Шаблоны корпоративных приложений., 2020.

Подробнее..

Пишем телеграм бота на Go и AWS Lambda

03.05.2021 02:05:54 | Автор: admin

Что будем делать?

Будем писать простой телеграм бот, отвечающий тем же сообщением, что и отправили.

Почему AWS Lambda ?

  1. Удобство деплоя, просто пишешь sls deploy, и lambda уже выгружена

  2. Платишь только за время, когда lambda работает

  3. Не надо настраивать никаких серверов, и беспокоиться о масштабировании

Что понадобится?

  • Установленный go

  • Nodejs и npm для установки serverless

  • AWS аккаунт для деплоя

TLDR

  • Клонируем репозиторий https://github.com/R11baka/echotgbot

  • Устанавливаем в .env файле BOT_TOKEN переменную

  • Компилируем бинарник env GOOS=linux go build -o bin/webhook main.go

  • Выгружаем лямбду с помощью sls deploy

  • Устанавливаем webhook с помощью BOT_TOKEN

Регистрация в AWS

  • Регистирируем пользователя в AWS aws console и получаем aws_access_key_id, и aws_secret_access_key и прописываем их в .aws/credentials файле

Вот как выглядит мой .aws/credentials

cat ~/.aws/credentials[default]aws_access_key_id = ADEFEFEFFEBDXK3aws_secret_access_key = Zy6ewfir/zGaT1B2/o9JDWDSssdrlaregion = us-west-1

Регистрация бота

Для начала, нам надо зарегистировать бота в BotFather. Идем по ссылке, отправляем команду BotFather /newbot, придумываем имя боту, описание. В конце, BotFather вернет нам токен бота.Этот токен понадобится,нам для дальнейшей разработки.

Установка Serverless

Serverless-это framework, облегчающий настройку, деплой AWS Lambda функций. Написан на node, поэтому для его установки понадобится nodejs и npm. Устанавливаем serverless через npm

npm install -g serverless

После установки serverless проверяем, все ли установилось

sls -vFramework Core: 2.35.0 (standalone)Plugin: 4.5.3SDK: 4.2.2Components: 3.8.2

Теперь можно приступить к конфигурации serverless. Все настройки для serverless лежат в serverless.yml файле,который мы и создадим со следующим контентом

service: echoBotuseDotenv: trueconfigValidationMode: error # если в конфиге,чтото неправильно ,то ошибкаframeworkVersion: '>=1.28.0 <2.50.0'provider:  region: "us-west-1"  lambdaHashingVersion: "20201221"  name: aws  runtime: go1.x  logRetentionInDays: 30 # сколько дней хранить логи  endpointType: regional  tracing: # включаем трейсинг для лямбды    apiGateway: true    lambda: true  iam:    role:      statements:        - Effect: "Allow"          Resource: "*"          Action:            - "xray:*"package:  patterns:    - "bin/webhook" # деплоить только бинарникfunctions:  webhook:     handler: bin/webhook    timeout: 15    description: simple echo bot    memorySize: 128 # размер памяти в мегабайтах для функции    environment:      BOT_TOKEN: ${env:BOT_TOKEN}    events:      - http:          path: /webhook          method: ANY          cors: false

Имлементация логики бота на Go

  1. Устанавливаем библиотеки telebot.v2 и aws-lambda-go

      go mod init testBotgo: creating new go.mod: module testBot  go get -u gopkg.in/tucnak/telebot.v2go: gopkg.in/tucnak/telebot.v2 upgrade => v2.3.5go: github.com/pkg/errors upgrade => v0.9.1 go get github.com/aws/aws-lambda-gogo: github.com/aws/aws-lambda-go upgrade => v1.23.0
    

2. Создаем файл main.go с контентом

package mainimport ("encoding/json""fmt""github.com/aws/aws-lambda-go/events""github.com/aws/aws-lambda-go/lambda"tb "gopkg.in/tucnak/telebot.v2""os")func main() {settings := tb.Settings{Token:       os.Getenv("BOT_TOKEN"),Synchronous: true,Verbose:     true,}tgBot, err := tb.NewBot(settings)if err != nil {fmt.Println(err)panic("can't create bot")}tgBot.Handle(tb.OnText, func(m *tb.Message) {message := m.TexttgBot.Send(m.Sender, message)})lambda.Start(func(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {var u tb.Updateif err = json.Unmarshal([]byte(req.Body), &u); err == nil {tgBot.ProcessUpdate(u)}return events.APIGatewayProxyResponse{Body: "ok", StatusCode: 200}, nil})}

Текущая документация в tucnak/telebot.v2 немного устарела, и если просто написать return вместо return events.APIGatewayProxyResponse{Body: "ok", StatusCode: 200}, nil, то телеграм будет повторно отправлять сообщения к боту.

Deploy бота

  • Создаем файл .env и вставляем API_TOKEN полученный от BotFather

    echo API_TOKEN={API_TOKEN_FROM_BOTFATHER} > .env
    
  • Проверяем serverlss конфиг с помощью команды. Не должно быть никаких ошибок.

    sls print
    
  • Потом собираем бинарник

    env GOOS=linux go build  -o bin/webhook main.go 
    
  • И выгружаем его с помощью serverless

    serverless deploy  -v
    

    При успешной выгрузке, мы получим в конце

    Service Informationservice: echoBotstage: devregion: us-west-1stack: echoBot-devresources: 11api keys:Noneendpoints:ANY - https://y7p31bwnu1.execute-api.us-west-1.amazonaws.com/dev/webhookfunctions:webhook: echoBot-dev-webhooklayers:None
    

    https://y7p31bwnu1.execute-api.us-west-1.amazonaws.com/dev/webhook => этот эндпоинт и token бота нужен нам, чтобы установить webhook

Интеграция с telegram

Осталось сообщить телеграму, какой эндпоинт дергать при получении сообщения. Делается это командой setWebhook

curl https://api.telegram.org/bot{YOUR_TOKEN}/setWebhook?url={YOUR_DEPLOYED_AWS_URL}

Проверка что webhook установлен, происходит с помощью getWebhookInfo

  ~ curl https://api.telegram.org/bot1324913549:AAE1zYMH6K3hF2TOgUQoIP-E1g4rMIamck/setWebhook\?url\= https://y7p31bwnu1.execute-api.us-west-1.amazonaws.com/dev/webhook{"ok":true,"result":true,"description":"Webhook was set"}  ~ curl https://api.telegram.org/bot1324913549:AAE1zYMH6K3hF2TOgUQoIP-E1g4rMIamck/getWebhookInfo{"ok":true,"result":{"url":"https://y7p31bwnu1.execute-api.us-west-1.amazonaws.com/dev/webhook","has_custom_certificate":false,"pending_update_count":0,"max_connections":40,"ip_address":"184.169.148.254"}}

Ошибки

Если что-то пошло не так,идем в CloudWatch и смотрим логи,или же из консоли также можно посмотреть логи

sls logs -f webhook
Подробнее..
Категории: Go , Golang , Serverless

Golang-дайджест 5 (1 31 мая 2021)

01.06.2021 16:23:51 | Автор: admin

Свежая подборка новостей и материалов

Интересное в этом выпуске

  • Выпущены версии Go 1.16.4 и 1.15.12

  • Эмулятор Atari VCS

  • Эмулятор Game Boy

  • Фантастическая библиотека 2D-игр

Приятного чтения!

Новости, события

Предложения по улучшению языка

Материалы для обучения

Уроки для изучения Golang

Введение в программирование на Go

Go в примерах

Маленькая книга о Go

Руководство для начинающих по разумным абстракциям с использованием Golang

Книга, посвященная синтаксису/семантике

Go-patterns (паттерны проектирования с примерами на Golang)

Статьи

Инструменты

  • Web-компоненты на Go gomponents

  • Простенькое решение для фича флагов go-feature-flag

  • Виртуальные машины Linux с автоматическим общим доступом к файлам, переадресацией портов и контейнером lima

  • Инструмент для безопасного разделения и распространения ваших закрытых ключей cocert

  • Создание конвейеров сборки в Go на этот пакет сильно повлиял пакет тестирования, что становится очевидным при запуске конвейера goyek

  • Простая структура RPC с определениями служб Protobuf twirp 8.0

  • Инструмент для генерации безопасных типов из SQL sqlc 1.8

  • Фантастическая библиотека 2D-игр Ebiten 2.1.0

  • Веб-фреймворк, вдохновленный Express.js. Так что, если вам нравится это в мире узлов, проверьте это Fiber 2.9.0

  • Реализации фильтров Блума Bloom 3.0

  • Высокопроизводительный минималистичный веб-фреймворк Echo 4.3

  • Усовершенствованный HTTP-клиент Heimdall 7.0

  • Основанная на графах база данных, написанная на Go EliasDB

  • Выполнение JavaScript из Go v8go 0.6.0

  • Терминальный клиент для Mangadesk

  • Middleware http для безопасности Secure

  • Простое и полное решение для флагов функций go-feature-flag

  • Управление доступом на основе ролей (RBAC) с сохранением базы данных Authority 2.0

  • Client для New Notion API go-notion

  • Эмулятор Atari VCS относительно полнофункциональный эмулятор, который включает поддержку контроллера и запись игрового процесса Gopher2600

  • Эмулятор Game Boy Advance, написанный на Go не для того, чтобы играть в фаворитов с классическими игровыми устройствами. Как насчет эмулятора для GBA от Nintendo Magia

  • Библиотека MySQL go-mysql 1.2.0

  • Менеджер email-рассылки listmonk

  • Экспериментальный инструмент рефакторинга Rf

  • GitHub Actions для сохранения старых зависимостей go-mod-archiver

  • Анализатор использования диска с консольным интерфейсом gdu 5.0

  • Быстрый, гибкий Linter for Go Revive

  • Библиотека изменения размера изображения с учетом содержимого Caire 1.3

  • Простая проверка типа JSON go-map-schema

  • Пакет вывода цветного текста в консоли color 1.12.0

  • Визуализация ответов в формате JSON, XML, двоичных данных и HTML-шаблонов Render 1.4.0

  • Официальный клиент Go для Elasticsearch go-elasticsearch 7.13.0

  • База данных SQL на чистом Go go-sqldb

  • io.Readerа io.Writer с ограничениями LimitIO

Видео

Плейлист с GopherCon Russia 2021

5-минутное видео, показывающее, насколько легко запускать и отлаживать тесты с помощью Delve

Подкасты

GenericTalks

Go Time

Сообщества

Вопросы по языку на русскоязычном StackOverflow

Информация о митапах

Форум в группах Google

https://t.me/vseins_tech

Подробнее..
Категории: Go , Golang , Digest

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru