Начнём с того, что я обожаю сериализацию в Unity. Она надёжна и очень проста в использовании. Я просто расширяю MonoBehaviour, ScriptableObject и подобные классы и настраиваю сериализуемые поля экземпляров в инспекторе.

Но у неё есть и слабости. Одна из них ― человеческий фактор. Представьте себе огромный проект, который живёт несколько лет и над которым работает около сотни человек. И любой из них может совершить ошибку: оставить пустую ссылку на объект, указать число вне диапазона, ввести строку в неверном формате, заполнить массив слишком маленьким или, наоборот, слишком большим количеством объектов. Уверен, у каждого из вас найдутся такие примеры из своего опыта. Причин и оправданий тоже множество: невнимательность, неожиданные последствия слияния веток, сбои редактора… И никто от этого не застрахован.

Такие ошибки до поры до времени остаются незаметными: компилятору до них нет дела, в отличие от опечаток в коде. Особенно неприятны они тем, что проявляются часто уже во время выполнения кода. Только тогда вы начинаете читать журнал сообщений и идёте проверять данные: тыкать их в редакторе или листать YAML. Но объектов может быть достаточно много, есть риск что-то пропустить или попросту залениться.

Конечно, можно добавить проверок в коде, но от этого он загрязнится. Иногда эти проверки негативно влияют на производительность. А ещё не всегда однозначно понятно, как именно обработать каждую конкретную ошибку.

Универсального или даже штатного метода бороться с подобным в Unity нет. Поэтому мы в Pixonic реализовали свою систему валидации ассетов. И это очень помогает нам жить.

Сейчас я опишу, как там всё устроено.

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

public class LookAtTarget : MonoBehaviour
{
    [SerializeField, ExpectNotNull] private Transform target;

    // ...
}

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

[Error] Reference is null
 Property: target
 Attribute: ExpectNotNullAttribute
 Script: Game.Core.LookAtTarget
 Object: Turret
 Asset: Assets/Scripts/Game/Test.unity

В режиме batch mode у нас собирается отдельный отчёт обо всех нарушениях. Опционально существует возможность завершить сборку в CI (Continuous Integration) неудачно, если хотя бы одно ожидание с Severity.Error не сработало.

Именно эта деталь даёт нам хорошие (но не стопроцентные!) гарантии того, что данным можно доверять, и проверять их дополнительно, скорее всего, не следует.

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



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

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

public class ExpectStringLengthAttribute : ExpectationWithSeverityAttribute
{
    public readonly int Min;
    public readonly int Max;

    public ExpectStringLengthAttribute(int min, int max) => (Min, Max) = (min, max);
}

И сам валидатор длины строки:

[Validator(typeof(ExpectStringLengthAttribute))]
public class ExpectStringLengthValidator : IValidator
{
    void IValidator.Validate(SerializedProperty property, ExpectationAttribute attribute, IList<Issue> output)
    {
        if (property.propertyType != SerializedPropertyType.String)
        {
            output.AddTypeNotSupported(attribute, property.type);
            return;
        }

        var length = property.stringValue.Length;
        var rangeAttribute = (ExpectStringLengthAttribute) attribute;
        if (length < rangeAttribute.Min || length > rangeAttribute.Max)
        {
            output.Add(
                rangeAttribute.Severity,
                attribute,
                "Length out of range [{0}, {1}]", rangeAttribute.Min, rangeAttribute.Max);
        }
    }
}

Для чего нужны эти два класса? Класс атрибута должен находиться в сборке с основным кодом игры: это просто метка с параметрами. Код класса валидатора запускается только в контексте редактора и поэтому должен находиться в соответствующей сборке (в поддиректории Editor). После загрузки скриптов в контексте редактора мы обходим сборки и составляем статический словарь с ключами в виде типов атрибутов и значениями в виде экземпляров валидатора.

[DidReloadScripts]
public static void ReloadScripts()
{
    var validatorType = typeof(IValidator);
    _validators.Clear();

    foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
    {
        foreach (var type in assembly.GetTypes())
        {
            if (type == validatorType || !validatorType.IsAssignableFrom(type))
            {
                continue;
            }

            foreach (var attribute in type.GetCustomAttributes(typeof(ValidatorAttribute), true))
            {
                _validators.Add(
                    ((ValidatorAttribute) attribute).AttributeType,
                    (IValidator) Activator.CreateInstance(type));
            }
        }
    }
}

Этот словарь далее используется как в постобработке, так и при отрисовке инспектора.

public static void Validate(SerializedProperty property, FieldInfo fieldInfo, IList<Issue> output)
{
    var attributes = fieldInfo.GetCustomAttributes(typeof(ExpectationAttribute), true);
    for (int i = 0, count = attributes.Length; i != count; ++i)
    {
        var propertyAttribute = (ExpectationAttribute) attributes[i];

        if (_validators.TryGetValue(propertyAttribute.GetType(), out var validator))
        {
            validator.Validate(property, propertyAttribute, output);
        }
    }
}

Во время отрисовки инспектора вызвать валидацию легче, так как в PropertyDrawer сразу доступны и property, и fieldInfo. Во время постобработки дело обстоит сложнее, так как обходить всё сериализуемое дерево приходится вручную, параллельно через рефлексию собирая FieldInfo для каждого SerializedProperty. Это достаточно объёмный код, поэтому я не буду добавлять его в статью, но здесь можно посмотреть, как это делается.

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

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