Валидация ассетов в Unity3D
Начнём с того, что я обожаю сериализацию в 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 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 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. Это достаточно объёмный код, поэтому я не буду добавлять его в статью, но здесь можно посмотреть, как это делается.
В иных сферах подобная валидация будет слишком слабой защитой от непредвиденных ошибок, но для наших целей такой способ вынести некоторые проверки из этапа времени выполнения на этапы редактирования и сборки вполне себя оправдывает. Это ускоряет разработку уже хотя бы тем, что мы можем точно увидеть, где и кто что-то сломал, снижая влияние человеческого фактора на качество сборки.