DevGang
Авторизоваться

Валидация данных: Исследование ответственности кода

Наша цель как ответственных разработчиков - писать код, который не только функционален, но и понятен, удобен для сопровождения и адаптации. В этом процессе мы часто сталкиваемся с ключевым, но несколько неуловимым вопросом: проблема ответственности в чистом коде. Подождите, не закрывайте пока страницу. Я не собираюсь описывать хорошо известный принцип единой ответственности (Single Responsibility Principle, SRP). Вместо этого я хочу рассмотреть нечто более широкое и, тем не менее, иногда более сложное - где должны располагаться конкретные функциональные возможности в архитектуре системы?

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

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

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

Ещё один взгляд на архитектуру многоуровневых услуг

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

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

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

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

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

  • Инфраструктурный слой с синхронными обработчиками HTTP/gRPC/консолей, асинхронными потребителями событий/команд.
  • Доменный слой со всей бизнес-логикой.
  • Управляемый инфраслой с хранилищами баз данных, кэшами, издателями асинхронных событий/команд, клиентами для других сервисов.

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

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

Место валидации очевидно. Но не совсем.

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

Типичный рамочный подход к валидации

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

  • В Java Spring вы можете использовать аннотацию @Valid в параметре контроллера, а также специальные аннотации для каждого важного поля в DTO (Data Transfer Object).
@Entity
public class User {
    @NotNull
    @Size(min = 5, max = 255)
    private String name;

    @NotNull
    @Min(18)
    @Max(150)
    private Integer age;
}

@RestController
public class UserController {
    @PostMapping("/users")
    ResponseEntity<String> addUser(@Valid @RequestBody User user) {
        // ...
    }
}
  • Фреймворк PHP Laravel обычно включает в себя явную валидацию запросов прямо в контроллере.
public function addUser(Request $request) {
   $validated = $request->validate([
      'name' => 'required|min:5|max:255',
      'age' => 'numeric|min:18|max:150',
   ]);
}
  • В Golang, несмотря на реальное отсутствие фреймворков, многие библиотеки придерживаются схожего подхода к валидации. Давайте рассмотрим пример с библиотекой github.com/go-playground/validator/v10:
type User struct {
    Name string `json:"name" validate:"required,min=5,max=255"`
    Age  int    `json:"age" validate:"gte=18,lte=150"`
}

func (a *API) createUserHandler(w http.ResponseWriter, r *http.Request) {
    var user User

    err := json.NewDecoder(r.Body).Decode(&user)
    if err != nil {
        // write error to the response
        return
    }

    validate := validator.New()

    err = validate.Struct(user)
    if err != nil {
        errors := err.(validator.ValidationErrors)
        // write errors to the response, 
        // most likely mapping specific validator errors to something
        // that can be understood by a client
        return
    }

    user, err = a.userService.CreateUser(user.Name, user.Age)
    if err != nil {
        // write error to the response
        return
    }

    w.WriteHeader(http.StatusOK)
    // write created user to the response
}

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

Более подробный анализ ответственности слоев

При более глубоком рассмотрении ролей уровня входящей связи и уровня домена возникает другой подход к проверке:

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

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

На самом деле уровень входящей связи (читай "HTTP-обработчик/контроллер") может выполнять только базовые проверки, такие как обеспечение корректного формата данных (например, корректного JSON), проверка размера полезной нагрузки и другие проверки, связанные с его зоной ответственности.

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

Такой подход отличается от простого использования библиотеки проверки в HTTP-контроллере/сборщике. Он создает четкое разделение ответственности между инфраструктурой (уровень входящей связи) и уровнем домена за выполнение задачи проверки данных:

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

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

Давайте посмотрим на измененный пример на Golang с рассмотренным разделением обязанностей:

package api

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func (a *API) createUserHandler(w http.ResponseWriter, r *http.Request) {
    var dto User

    err := json.NewDecoder(r.Body).Decode(&dto)
    if err != nil {
        // write error to the response
        return
    }

    user, err = a.userService.CreateUser(dto.Name, dto.Age)
    if err != nil {
        // proper domain error handling
        return
    }

    w.WriteHeader(http.StatusOK)
    // marshal user entity and write to the response
}
package domain

type User struct {
    Name string
    Age  int
}

var ErrUserTooYoung := NewValidationError("user_too_young")
var ErrUserTooOld   := NewValidationError("user_too_old")
// define other needed errors

func NewUser(name string, age int) (*User, error) {
    if age < 15 {
        return nil, ErrUserTooYoung
    }

    if age > 150 {
        return nil, ErrUserTooOld
    }

    // ... other validation checks

    return User{Name:name, Age:age}, nil
}

type UserService struct {
    // ...
}

func (s *UserService) CreateUser(name string, age int) (*User, error) {
    user, err := NewUser(name, age)
    if err != nil {
        return nil, err
    }

    // store user in the database

    return user, nil
}

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

Скажем, с возрастом (Age) в качестве объекта Value Object пример может выглядеть следующим образом:

package domain

type Age int

func NewAge(age int) (Age, error) {
    if age < 15 {
        return 0, ErrUserTooYoung
    }

    if age > 150 {
        return 0, ErrUserTooOld
    }

    return Age(age), nil
}

type User struct {
    Name string
    Age  Age
}

// NewUser already accepts the Age value object instead of a generic int.
func NewUser(name string, age Age) (*User, error) {
    // ... other validation checks

    return User{Name:name, Age:age}, nil
}

func (s *UserService) CreateUser(name string, age int) (*User, error) {
    // here we create age as a value object which validates itself
    userAge, err := NewAge(age)
    if err != nil {
        return nil, err
    }

    user, err := NewUser(name, userAge)
    if err != nil {
        return nil, err
    }
}

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

Преимущества

  • Благодаря разделению обязанностей по проверке на разных уровнях код становится более организованным. Входящий коммуникационный уровень обрабатывает базовую целостность данных, а уровень домена управляет сложной проверкой бизнес-правил.
  • Благодаря тому, что каждый уровень выполняет свои проверки, система становится более устойчивой к недействительным или вредоносным данным, что снижает уязвимость системы безопасности. Особенно когда к сервису добавляются другие типы транспортировки, например, у вас есть HTTP и добавляются gRPC или async-обработчики.
  • Нет привязки к конкретной библиотеке валидации, всё ясно и понятно для других разработчиков.

Потенциальные трудности

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

Заключение

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

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

Я надеюсь, что эта статья заставила вас задуматься и была полезной. И я буду рад услышать ваши отзывы!

Ваши мнения очень важны, и я очень хочу продолжить разговор на эту тему. Спасибо, что читаете, и следите за новыми статьями! ❤️

Источник:

#JavaScript #Golang
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться