Клиентоцентричность с точки зрения Go-разработчика и причем тут рефлексия
- среда, 11 декабря 2024 г. в 00:00:08
Когда всё, что делает компания — от продуктов, услуг и до обслуживания — направлено на то, чтобы клиент был доволен и возвращался снова, все команды объединяются вокруг единой цели — смотреть на задачи и искать решения с точки зрения проблем и нужд конечного клиента.
Я Александр Шакмаев — технический лидер в Cloud.ru. Поделюсь опытом нашей команды: расскажу, как с помощью gRPC-интерцепторов и рефлексии команда Go-разработчиков может изменить продукт и улучшить пользовательский опыт.
Сразу отмечу, что большинство наших API-сервисов мы пишем на основе gRPC, а магию с REST и обработкой HTTP-запросов нам помогает осуществить Sidecar в виде Envoy в контексте Istio.
Клиентоцентричность в ее классическом понимании — это постоянное улучшение взаимоотношений с клиентом. Как мы в команде разработки можем поучаствовать в этом процессе или на него повлиять? Как минимум, не возвращать пользователю ошибки лишний раз, когда в запрос случайно проскочил пробел, символ табуляции или переноса строки.
Вот пример такой ошибки:
Здесь пользователь ввел JWT-токен в заголовок авторизации и случайно скопировал символы переноса в строке. Сервер тут же отвечает, что в JWT-токен пришел какой-то неопознанный символ. Получается, пользователь вынужден самостоятельно искать проблему и исправлять ее.
Есть и другие примеры — случайно проскочившие лишние пробелы или нетипичные для параметра символы. Здесь, например, пользователю вернули ошибку, так как он случайно передал в UUID пробел:
Давайте разберемся, как обрабатывать такие случаи в Go корректно — исключать лишние символы из каждого gRPC-запроса и не беспокоить пользователя лишний раз. При этом наша фича будет работать для всех новых методов, которые добавят разработчики в будущем.
Есть множество исследований в области микровзаимодействия с пользователем на уровне дизайна и frontend-части. А что по поводу backend? Известный факт — внимание к небольшим деталям может дать мощный результат.
Любая мелочь может повлиять на отношение пользователя к продукту, сервису и всей компании. В наших силах не портить впечатление на уровне микровзаимодействия с клиентом.
Итак, нам нужно вмешаться в запрос пользователя и исправить незначительную ошибку. Что же делать и какие вообще есть варианты? В Go есть несколько способов, как самостоятельно нормализовать нетипичные данные в gRPC-запросе.
Первое, что приходит на ум — попытаться резать лишнее на фронте. Казалось бы, идея плохая, но если подумать…
…действительно, плохая 😀! Поэтому подумаем еще.
И в голову приходит новая идея — валидировать и модифицировать запросы внутри всех контроллеров, которые обеспечивают API взаимодействие с пользователем.
Что если поставить какой-нибудь модификатор во все контроллеры, который просто будет проверять, есть ли во входящем запросе ненужные символы в выбранных полях, а затем обрабатывать регулярками или тримить данные (например, Trim).
Первая и очевидная проблема — повторяемость кода. Нам заранее известно, что наш код точно будет повторяться, ведь мы пытаемся в разных контроллерах запускать одну и ту же операцию. Конечно, можно сделать какую-то общую функцию, которая бы делала одну и ту же операцию. Наш любимый dry, single responsibility. Мы все это любим, но...
… даже при таком варианте придется, хоть и немного, но поменять код всех контроллеров. Звучит так себе, согласитесь? И если говорить про будущий технический долг, то получается, что разработчики должны всегда помнить, что для нового контроллера обязательно нужен метод для обработки запросов. А это очень неудобно.
Поэтому мы в команде продолжили искать варианты, как изменить запрос максимально сократив задачу разработки. И в конце концов придумали 🙂.
Нам на помощь пришли gRPC-интерцепторы, которые можно поставить между клиентом и сервером и проксируя запрос попытаться поменять данные запроса внутри хендлера.
Но тут на сцену выходит знакомая проблема — строгая типизация в языке Go. В любой общей функции придется делать свитчеры для обхода и разбора типов полей входящих структур. Поэтому добавим немного магии рефлексии и вот он — идеальный вариант модификации запроса в едином месте.
func UnaryServerInterceptor(opts ...Option) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ any, err error) {
value := reflect.ValueOf(req)
handleFields(&value)
return handler(ctx, req)
}
}
Сначала мы просто встраивались и перехватывали запрос, затем в этот запрос в функции handleFields
мы отправляли уже целевой запрос — посмотреть, что в нем не так, и если надо, то модифицировать:
func handleField(field *reflect.Value) {
if field.IsValid() && field.CanSet() {
switch field.Kind() {
case reflect.String:
trimValue := strings.Trim(field.String(), " \r\n\t")
field.SetString(trimValue)
case reflect.Struct, reflect.Pointer:
// рекурсивно обрабатываем поля, если это структуры
// Pointer - т.к. в grpc все вложенные структуры - указатели
handleFields(field)
default:
return
}
}
}
Затем пошли чуть дальше, и разработали механизм, который позволяет задавать конкретные имена полей для модификации значений только в этих полях:
func handleFields(value *reflect.Value, fieldNames ...string) {
elem := value.Elem()
if elem.Kind() == reflect.Struct {
if len(fieldNames) > 0 {
for _, name := range fieldNames {
field := elem.FieldByName(name)
handleField(&field)
}
} else {
for i := 0; i < elem.NumField(); i++ {
field := elem.Field(i)
handleField(&field)
}
}
}
}
В коде видно, что функция распознает значение по типу. И если это строка — то мы смело можем триммить по краям лишний пробел, переносы строки, перенос каретки или табуляцию, а затем возвращать обратно.
В ходе тестирования мы обнаружили, что не обрабатываем случаи, когда в запросе лежат структуры, а в структурах — структуры, и так далее рекурсивно. Тут нам на помощь и пришла рефлексия. В итоге немного доработали схему и теперь, если к нам в запросе прилетает структура, которая бесконечно разбирает структуры до тех пор, пока их не разберет до конца, мы обходим ее рекурсивно.
Мы решили померить, какой из двух способов работает быстрее всего — модификация на уровне контроллера или на уровне интерцепторов с применением рефлексии. Для этого использовали инструмент нагрузочного тестирования gRPC — ghz.
С помощью ghz можно:
делать параллельную отправку запросов;
поддерживать различные методы аутентификации, включая токены;
задавать данные запроса через файлы;
генерировать отчеты в форматах HTML, CSV и JSON, для последующего анализа результатов.
Запустили стандартный бенчмарк для двух основных вариантов. Сначала реализовали такую модификацию:
ghz -c 100 -n 1000000 --insecure \
--proto api/proto/hello.proto \
--call hello.HelloService.HelloCloudRu \
-d '{"name":"Александр ", "uuid":" 51478fd5-8fad-4efc-9c14-54219b2d400d"}'
И вот такие результаты получили:
Существенной разницы между подходами замечено не было, но однозначно можно сказать одно — модификация запросов через интерцепторы происходит немного быстрее:
Если интересны подробности — добро пожаловать в репозиторий с бенчмарками.
Кажется, создавать приложения с учетом клиентоцентричного подхода — не очень сложная задача. Рефлексия в интерцепторах помогает динамически извлекать и изменять информацию о данных запроса в реальном времени, что способствует большей гибкости в обработке пользовательских запросов.
Использование рефлексии и grpc-интерцепторов помогает в создании более адаптивных и ориентированных на клиента решений, обеспечивая конкурентное преимущество и улучшая пользовательский опыт.
Ну правда, зачем клиентам возвращать ошибку при валидном JWT-токене, если туда случайно попал пробел или перенос строки?
Кроме того, разработчикам не нужно заранее предугадывать все возможные варианты данных в запросах, что в свою очередь позволяет более оперативно реагировать на обратную связь от клиентов и внедрять необходимые изменения с минимальными усилиями.
Другие публикации в блоге: