Валидация ассетов в Unity3D

twilscwcuxcwls8hn2-2d6gvkos.png

Начнём с того, что я обожаю сериализацию в 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 не сработало.

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

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

z-vnx3h3bggirf7bnph2y-k2ezm.png

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

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

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

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

© Habrahabr.ru