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

Я Александр Шакмаев — технический лидер в Cloud.ru. Поделюсь опытом нашей команды: расскажу, как с помощью gRPC-интерцепторов и рефлексии команда Go-разработчиков может изменить продукт и улучшить пользовательский опыт.

Сразу отмечу, что большинство наших API-сервисов мы пишем на основе gRPC, а магию с REST и обработкой HTTP-запросов нам помогает осуществить Sidecar в виде Envoy в контексте Istio.

Клиентоцентричность со стороны разработчика: как это работает у нас

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

Вот пример такой ошибки:

Сервер вернул ошибку потому, что пользователь случайно перенес строку в конце JWT-токена
Сервер вернул ошибку потому, что пользователь случайно перенес строку в конце JWT-токена

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

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

Сервер вернул пользователю ошибку потому, что в запрос попал пробел
Сервер вернул пользователю ошибку потому, что в запрос попал пробел

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

Есть множество исследований в области микровзаимодействия с пользователем на уровне дизайна и frontend-части. А что по поводу backend? Известный факт — внимание к небольшим деталям может дать мощный результат.

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

Модифицируем запросы пользователей

Итак, нам нужно вмешаться в запрос пользователя и исправить незначительную ошибку. Что же делать и какие вообще есть варианты? В Go есть несколько способов, как самостоятельно нормализовать нетипичные данные в gRPC-запросе. 

Первый способ — вырезаем лишнее на frontend

Первое, что приходит на ум — попытаться резать лишнее на фронте. Казалось бы, идея плохая, но если подумать… 

…действительно, плохая ?! Поэтому подумаем еще. 

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

Второй способ — правим код внутри всех контроллеров

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

Первая и очевидная проблема — повторяемость кода. Нам заранее известно, что наш код точно будет повторяться, ведь мы пытаемся в разных контроллерах запускать одну и ту же операцию. Конечно, можно сделать какую-то общую функцию, которая бы делала одну и ту же операцию. Наш любимый dry, single responsibility. Мы все это любим, но...

… даже при таком варианте придется, хоть и немного, но поменять код всех контроллеров. Звучит так себе, согласитесь? И если говорить про будущий технический долг, то получается, что разработчики должны всегда помнить, что для нового контроллера обязательно нужен метод для обработки запросов. А это очень неудобно.

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

Оптимальный способ — используем интерцепторы gRPC

Нам на помощь пришли 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"}'

И вот такие результаты получили:

Бенчмарк кода с вариантом модификации запросов в контроллере
Бенчмарк кода с вариантом модификации запросов в контроллере
Бенчмарк кода с вариантом модификации запросов в едином интерцепторе
Бенчмарк кода с вариантом модификации запросов в едином интерцепторе

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

Сравнение скорости модификации запросов через Trim в контроллере (второй способ) и через Middleware (способ с интерцепторами)
Сравнение скорости модификации запросов через Trim в контроллере (второй способ) и через Middleware (способ с интерцепторами)

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

Заключение

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

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

Ну правда, зачем клиентам возвращать ошибку при валидном JWT-токене, если туда случайно попал пробел или перенос строки?

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

Другие публикации в блоге:

Комментарии (8)


  1. lazy_val
    10.12.2024 13:33

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

    А где в теле функции распознавание именно строки?

    if elem.Kind() == reflect.Struct {
    ...
    }
    

    Здесь распознаем на входе именно структуру. А не какой-нибудь bytes.Buffer, к примеру. А где распознавание строк?

    EDIT. А, стоп, нашел. Это в предыдущей функции handleField()


    1. echo0x00 Автор
      10.12.2024 13:33

      switch field.Kind() {
      		case reflect.String:

      Если это строка (см функцию handleField)
      А тут в handleFields- если снова структура, значит лезем внутрь


      1. lazy_val
        10.12.2024 13:33

        ну да, все правильно

        я просто сначала искал "распознавание строки" в handleFields(). А надо было одним окном выше, в handleField() ))


  1. evgeniy_kudinov
    10.12.2024 13:33

    Спасибо, полезно. Похоже на "санизацию" входных данных.


  1. ertaquo
    10.12.2024 13:33

    Какие-то странные кейсы. Пользователь в принципе не должен самостоятельно вводить JWT-токен или UUID, а при отправке на сервер данные должны быть корректными.

    Даже если исправлять их на стороне сервера, что мешает сделать как-то так:

    UUID.parse(strings.TrimSpace(req.Id))

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


    1. echo0x00 Автор
      10.12.2024 13:33

      Спасибо за комментарий.

      На самом деле, есть кейсы когда jwt токены люди генерируют и руками копируют еще на этапе тестирования public api. И уже этот кейс может складывать у пользователей начальное впечатление - вот тут и клиентоцентричность.

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

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

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

      Тут можно много примеров за и против привести. Но важно, то - что у если прямо сейчас в проекте 100 контроллеров которые кидают эту бессмысленную ошибку при валидации uuid - в разы проще написать интерцептор (с привязкой к конкретному полю, если нужно), чем править логику 100 контроллеров - об этом описано во «втором способе»

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


  1. bekhruz
    10.12.2024 13:33

    Абсолютно непонятно, как вообще ввод пользователем UUID и JWT-токена изначально могли быть клиентоориентированными и если необходимо почистить данные, почему бы не добавить пару строк кода в тот же обработчик?

    Ну а если это примеры, то они подобраны не к месту, скорее усложнили понимание


    1. echo0x00 Автор
      10.12.2024 13:33

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

      Минусы добавить пару строк в тот же обработчик описаны во «втором способе» статьи.