Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3

Аватарка пользователя Алексей Соломонов

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

Поговорим о том, как валидировать данные, которые отправляют пользователи нашему веб-приложению. Это — третья статья из цикла о том, как разработать коммерческое веб-приложение с нуля (Часть 1, Часть 2).

  1. Вводная.
  2. Требования к валидации проекта.
  3. Валидатор.
  4. Middleware.
  5. Правила валидации запросов
  6. Тестирование.
  7. Итоги.

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

  1. Пользователи могут не вводить обязательные данные без которых работа приложения невозможна.
  2. Пользователи могут вводить неверные данные из-за невнимательности.
  3. Злоумышленники могут целенаправленно вводить неверные данные.

Итог всех этих действий один — приложение может работать неверно, данные могут быть не консистентны.

Требования к валидации проекта

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

  1. Имя должно содержать только буквы и иметь длину не более 30 символов.
  2. Дата должна соответствовать формату RFC3339.
  3. Телефонный номер должен соответствовать международным рекомендациям.

Следовать стандартам и рекомендациям хорошо, потому что:

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

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

Валидировать данные можно двумя путями:

  1. На уровне описания протокола.
  2. На уровне приложения.

Валидация на уровне описания протокола

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

  1. github.com/mwitkow/go-proto-validators
  2. github.com/envoyproxy/protoc-gen-validate

У валидации на уровне протокола есть три проблемы:

  1. Одни и те же правила (например дата должна быть в формате RFC3339) необходимо добавлять в разные сообщения. Соответственно есть риск, что разработчик ошибется, и валидация будет работать неверно или не будет работать вообще.
  2. Для какого то типа данных необходимо изменить валидацию. Если вы не нашли способ, как не дублировать правила валидации, тогда ее изменение превратится в проблему.
  3. Допустим вы решили первые две проблемы, но в какой то момент времени ваше приложение будет интегрироваться с другим, которое умеет отдавать данные только в xml. Соответственно вам не удастся переиспользовать правила валидации и надо будет что то придумывать.

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

Валидация на уровне приложения

В golang большое количество библиотек, осуществляющих валидацию данных, поэтому можно выбрать любую понравившуюся и добавить в проект. Стоит сказать, что со всеми подобными библиотеками есть одна проблема — они не умеют работать с архитектурой grpc «из коробки», поэтому придется писать адаптеры над ними.

Для написания адаптеров к нам на помощь приходят:

  1. google.golang.org/protobuf/reflect/protoreflect поможет нам итеративно получить значения по всем полям сообщения.
  2. github.com/go-playground/validator станет фундаментом, на котором мы построим механизм валидации.

Архитектура валидации будет иметь следующую логику:

  1. Запрос перехватывается middleware.
  2. Middleware получает правила валидации из MethodDescriptor.
  3. Если правила есть, выполняет проверку свойств сообщения.
  4. Если нет, передает обработку запроса дальше.

Валидатор

Для начала давайте опишем сам валидатор и форму:

			package validator

import (
    "context"
    "github.com/go-playground/validator/v10"
    "google.golang.org/protobuf/reflect/protoreflect"
)

var (
    validate = validator.New()
)

// конкретная реализация для какого либо типа данных запроса
type Validator interface {
    Validate(ctx context.Context, value protoreflect.Value) error
}

// структура, позволяющая описывать валидаторы для всего запроса
type Form map[string]Validator

func (f Form) Validate(ctx context.Context, message protoreflect.Message) error {
    fields := message.Descriptor().Fields()
    for i := 0; i < fields.Len(); i++ {
        field := fields.Get(i)
        v, ok := f[string(field.Name())]
        if !ok {
            continue
        }

        err := v.Validate(ctx, message.Get(field))
        if err != nil {
            return err
        }
    }

    return nil
}
		

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

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

  1. uuid — строка в формате UUID v4.
  2. телефонный номер — строка с префиксом «+» и длиной не более 15 символов.
  3. адрес доставки — строка длиной 255 символов.
  4. дата доставки — дата в формате RFC3339 не раньше сегодняшнего дня.
  5. сумма заказа — положительное число с плавающей точкой.
  6. перечисление — значение enum больше нуля и меньше или равно максимальному.
  7. сообщение — вложенное сообщение.
  8. массив сообщений — вложенный массив сообщений.

Теперь давайте реализуем валидаторы для разных форматов строк:

			package validator

import (
    "context"
    "fmt"
    "google.golang.org/protobuf/reflect/protoreflect"
)

func NewString(condition string) Validator {
    return &stringValidator{
        condition: condition,
    }
}

func NewUUID() Validator {
    return NewString("uuid4")
}

func NewPhoneNumber() Validator {
    return NewString("e164")
}

func NewStringWithMaxLen(l int) Validator {
    return NewString(fmt.Sprintf("required,max=%d", l))
}

func NewReqString() Validator {
    return NewString("required")
}

func NewAlphanumeric() Validator {
    return NewString("alphanum")
}

type stringValidator struct {
    condition string
}

func (v stringValidator) Validate(ctx context.Context, value protoreflect.Value) error {
    return validate.VarCtx(ctx, value.String(), v.condition)
}
		

Протестируем получившиеся валидаторы:

			package validator_test

import (
    "context"
    "github.com/Pallinder/go-randomdata"
    "github.com/byorty/enterprise-application/pkg/common/adapter/validator"
    "github.com/google/uuid"
    "github.com/stretchr/testify/suite"
    "google.golang.org/protobuf/reflect/protoreflect"
    "testing"
)

func TestStringValidatorSuite(t *testing.T) {
    suite.Run(t, new(StringValidatorSuite))
}

type StringValidatorSuite struct {
    suite.Suite
}

func (s *StringValidatorSuite) TestUUID() {
    v := validator.NewUUID()
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
    s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(uuid.NewString())))
}

func (s *StringValidatorSuite) TestPhoneNumber() {
    v := validator.NewPhoneNumber()
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
    s.Nil(v.Validate(context.Background(), protoreflect.ValueOf("+79008007060")))
}

func (s *StringValidatorSuite) TestMaxLen() {
    v := validator.NewStringWithMaxLen(10)
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
    s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(9))))
}

func (s *StringValidatorSuite) TestAlphanumeric() {
    v := validator.NewAlphanumeric()
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
    s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("qazwsx!@#%%^")))
    s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
}
		

По аналогии реализуем другие валидаторы:

  1. Целые числа
  2. Числа с плавающей точкой
  3. Даты
  4. Перечисления
  5. Сообщения
  6. Массивы

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

Middleware

Теперь необходимо вписать наши валидаторы в архитектуру grpc.

Расширяем MethodDescriptor:

			type MethodDescriptor struct {
    …
    Form validator.Form
}
		

Реализуем новую middleware, которая будет брать форму из описания метода и валидировать запрос:

			package grpc_option

import (
    "context"
    "github.com/byorty/enterprise-application/pkg/common/adapter/server/grpc"
    gRPC "google.golang.org/grpc"
    "google.golang.org/protobuf/reflect/protoreflect"
)

func NewFxValidatorOption(
    methodDescriptorMap grpc.MethodDescriptorMap,
) grpc.MiddlewareOut {
    return grpc.MiddlewareOut{
        GrpcMiddleware: grpc.Middleware{
            Priority: 97,
            GrpcOption: func(ctx context.Context, req interface{}, info *gRPC.UnaryServerInfo, handler gRPC.UnaryHandler) (resp interface{}, err error) {
                protoMessage, ok := req.(protoreflect.ProtoMessage)
                if !ok {
                    return handler(ctx, req)
                }

                methodDescriptor, ok := methodDescriptorMap.GetByFullName(info.FullMethod)
                if !ok {
                    return handler(ctx, req)
                }

                if len(methodDescriptor.Form) == 0 {
                    return handler(ctx, req)
                }

                err = methodDescriptor.Form.Validate(ctx, protoMessage.ProtoReflect())
                if err != nil {
                    return nil, grpc.ErrInvalidArgument(err)
                }

                return handler(ctx, req)
            },
        },
    }
}
		

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

Правила валидации запросов

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

			package form

import "github.com/byorty/enterprise-application/pkg/common/adapter/validator"

var (
    CreateOrder = validator.Form{
        // идентификатором пользователя должен быть UUID v4
        "user_uuid": validator.NewUUID(),
        // параметрами запроса должнобыть сообщение
        "params": validator.NewMessage(validator.Form{
            // массив продуктов должен иметь хотя бы один элемент
            "products": validator.NewReqList(
                // элементом массива должно быть сообщение
                validator.NewMessage(validator.Form{
                    // идентификатором продукта должен быть UUID v4
                    "product_uuid": validator.NewUUID(),
                    // пользователь должен добавить в заказ хотя бы одну единицу продукта
                    "count":  validator.NewUint32Min(1),
                }),
            ),
            // любая строка не длиннее 255-ти символов
            "address":      validator.NewStringWithMaxLen(255),
            // дата равная текущей + двое суток
            "delivered_at": validator.NewDeliveredAt(),
        }),
    }
)
		

По аналогии опишем правила для других запросов:

  1. Запросы продуктов
  2. Запросы пользователей

Тестирование

Доработки кода приложения и инфраструктуры завершены, самое время протестировать наше приложение. Поднимаем приложение:

			make up
		

Открываем https://enterprise.application.local

Давайте зарегистрируем пользователя с невалидным телефоном.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 1

Получили ошибку валидации запроса.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 2

Теперь возьмем валидный номер телефона.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 3

Пользователь зарегистрирован.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 4

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

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 5

Получили ошибку валидации запроса.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 6

Отложим товары и создадим заказ.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 7

Заказ создан.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 3 8

Тесты прошли успешно, валидация запросов отрабатывает, как требуется.

Итоги

Итак, в этой статье мы научились:

  1. Разрабатывать архитектуру валидации protobuf сообщений.
  2. Реализовывать единый набор правил валидации.
  3. Вписывать правила валидации в архитектуру grpc.
  4. Описывать правила валидации для конкретных запросов.

Как и в предыдущей статье, код представлен в репозитории.

В следующей статье мы обсудим следующие темы:

  1. Выбор драйвера СУБД.
  2. Репозиторный слой приложения.
  3. Моки в тестах.
Веб-разработка
Приложение
Гостевая публикация
2070