Ранее я написал об этом пост, но потом набралось больше материала и я расширил до статьи.

Работая над одним из проектов, который недавно переехал из Framework 4.8 на Core 9, обнаружил множество самых разных вариантов использования модификатора required и атрибута Required, примерно каждый второй из которых был использован неправильно. Я написал это коллегам и хочу поделиться этим здесь. Это не обязательные правила, но сильно упрощают работу с кодом.


Атрибут Required и модификатор required

Атрибут Required нужен для проверки входящих преимущественно строковых данных в эндпоинтах. Возвращает ошибку, если значение null или пустая строка для строк (если не отключено параметром AllowEmptyStrings). Работает в Runtime. Также применяется в Entity Framework в подходе code-first но с включением опции <Nullable> в csproj про эти случаи можно забыть, сделав код чище.

Модификатор required нужен для обязательного указания значений полей при создании класса. Работает в Compile-time.

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

// имеем класс с required полем
public class Example
{
    public required string Name { get; set; }
}

// пытаемся создать экземпляр в коде
var example1 = new Example();  // будет ошибка при попытке сборки проекта
var example2 = new Example { Name = string.Empty };  // тут ошибки не будет

// Вывод: модификатор required нужен для разработчика
// имеем класс с полем, у которого атрибут Required
public class Example
{
    [Required]
    public string Name { get; set; }
}

// пытаемся создать экземпляр в коде
var example = new Example();  // проект спокойно собирается

// имеем эндпоинт в контроллере
public IActionResult PostMethod([FromBody] Example model) => Ok();

/* передаём в теле запроса:
{}
или
{"Name": null}
или
{"Name": ""}
или
{"Name": "   "}
Получаем BadRequest с текстом ошибки. */

// передаём в теле запроса: {"Name": "name"}. Получаем OK.

// Вывод: атрибут Required нужен для пользователя

Как стоит и не стоит использовать

public class BadExample
{
    public required string Field1 { get; set; } // 1
    
    public required string? Field2 { get; set; } // 2
    
    [Required]
    public required string Field3 { get; set; } // 3

    [Required]
    public string? Field4 { get; set; } // 4

    [Required]
    public int Field5 { get; set; } // 5

    public required int Field6 { get; set; } = 10; // 6
      
    public required List<int> Field7 { get; set; } // 7
}
  1. Ошибка, если класс используется как входящий параметр в эндпоинте. Соответственно, не стоит использовать, если десериализуем в него. Это создаёт избыточную сложность.

  2. Либо required, либо nullable.

  3. Надо выбрать одно из двух в зависимости от места использования.

  4. Либо Required, либо nullable.

  5. Required используем для строк. Но есть нюанс (*).

  6. Не нужно использовать required со значением по умолчанию.

  7. Не стоит усложнять жизнь, если поле можно проинициализировать при создании класса.

public class GoodExample
{
    public required string Field1 { get; set; } // 1
    
    [Required]
    public string Field2 { get; set; } = null!; // 2
    
    public string? Field3 { get; set; } // 3

    public int Field4 { get; set; } // 4

    public List<string> Field5 { get; set; } = []; // 5
}
  1. Хорошо где угодно за пределами эндпоинтов и десериализации, а значение не может принимать null.

  2. То что нужно для эндпоинта.

  3. Поле nullable. Поэтому никаких required.

  4. Не используем атрибут Required с не строками. Но есть нюанс (*).

  5. Избегаем использование required, проинициализировав коллекцию.

* - если передаётся json, в котором явно указано значение null ({"IntField": null}), то использование атрибута Required вернёт BadRequest с текстом ошибки валидации.
Если же в json поле было опущено, то будет присвоено значение по умолчанию.


Самописные атрибуты

Вот код, который скрывается за базовым атрибутом Required:

public override bool IsValid(object? value)
{
    if (value is null)
        return false;
    return AllowEmptyStrings || value is not string stringValue || !string.IsNullOrWhiteSpace(stringValue);
}

Допустим, я хочу написать свой атрибут, который будет проверять ещё и не nullable коллекции, чтобы там был хотя бы один элемент. Тут всё просто: создаём новый класс, который наследуется от RequiredAttribute и переписывается метод IsValid.

А если мне надо, чтобы атрибут возвращал ошибку не на одном языке, а в зависимости от настроек пользователя? Или мне ещё десяток уникальных атрибутов надо с подобными возможностями? Тут аналогично: наследуем новый класс уже от ValidationAttribute и переписываем чуть больше. А лучше даже создаём класс с общей логикой для всех будущих атрибутов, в которых вся внутренняя логика будет укладываться всего в несколько строк. Красота!

И там CustomRequiredAttribute, и тут CustomRequiredAttribute, "но есть нюанс", Петька. Валидация полей работает как по маслу, пока не решишь применить этот атрибут к параметрам эндпоинтов. Если мы воспользовались вторым вариантом, то внезапно выясним, что на каждый помеченный новым атрибутом параметр прилетает две ошибки, если он null. Перепроверяем, посылая пустую коллекцию - получаем одну ошибку. Перепроверяем с null - снова две.

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

builder.Services.AddControllers(options =>
    {
        options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
    });

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

// в .csproj: <Nullable>enable</Nullable>

[HttpPost]
public IActionResult SomeMethod([FromBody, CustomRequired] List<int> ids) => Ok();

Решение оказалось и очевидным, и нет. Чтобы заработало как надо, именно в этом случае самописный атрибут Required нужно наследовать от RequiredAttribute. Тогда ваш самописный атрибут подменяет собой базовую логику. Казалось бы, какая разница? Только вот RequiredAttribute у фреймворка на особом счету, что даже удостоился отдельной опции.
НО! С включением опции SuppressImplicitRequiredAttributeForNonNullableReferenceTypes все места, где RequiredAttribute срабатывал неявно, нужно теперь его или его наследника всегда явно указывать.

// тут всё без изменений
[HttpPost]
public IActionResult SomeMethod([FromBody] string? str) => Ok();

// с выключенной опцией раньше неявно срабатывает RequiredAttribute, если приходит null
[HttpPost]
public IActionResult SomeMethod([FromBody] string str) => Ok();

// со включенной опцией теперь нужно явно указывать, иначе пропустим null
[HttpPost]
public IActionResult SomeMethod([FromBody, Required] string str) => Ok();

Получается такая ситуация с самописными атрибутами:

// подходящий вариант для всего, кроме параметров
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class CustomRequiredAttribute
    : BaseCustomValidationAttribute // базовый для всех самописных со всей общей логикой
{
    public bool AllowEmptyStrings { get; set; }
    public bool AllowEmptyCollections { get; set; }
    
    public CustomRequiredAttribute()
        : base("Default error template")
    {
    }

    public override bool IsValidLogic(object value, ValidationContext validationContext) =>
        value switch
        {
            null => false,
            ICollection c => AllowEmptyCollections || c.Count > 0,
            string s => AllowEmptyStrings || !string.IsNullOrWhiteSpace(s),
            _ => true
        };
}
// подходящий вариант для всего, особенно для параметров
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class CustomRequiredAttribute
    : RequiredAttribute  // стандартный. Поэтому логику придётся писать отдельно
{
    public bool AllowEmptyStrings { get; set; }
    public bool AllowEmptyCollections { get; set; }
    
    public CustomRequiredAttribute()
        : base("Default error template")
    {
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var result = value switch
        {
            null => false,
            ICollection c => AllowEmptyCollections || c.Count > 0,
            string s => AllowEmptyStrings || !string.IsNullOrWhiteSpace(s),
            _ => true
        };
        if (result)
            return ValidationResult.Success;

        // некоторая логика, которая в ином случае была бы в BaseCustomValidationAttribute

        return new ValidationResult(...);
    }
}

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


Будьте внимательны в использовании слова required где бы то ни было в проекте и всегда пишите тесты. Как юнит-тесты, так и интеграционные, чтобы отлавливать подобные проблемы заранее. Именно тесты меня подтолкнули к написанию статьи.

Надеюсь, она поможет сделать код чище и избежать неоднозначностей.