В теории это звучит очень интересно: всё независимое, мелкое, сложные объекты — всего лишь разные комбинации из более простых. Недавно решил попробовать написать систему, которая будет работать по таким законам, и всё оказалось не так тривиально. За подробностями приглашаю под кат.
Наверное, каждому знакомо чувство, когда хочется поделиться своим решением с другими и обсудить его, узнать чужое мнение, возможно, помочь кому-то.
Работая над проектом для личных учебных целей, я вспомнил о «компонентно-ориентированном» подходе, вновь ощутил все его плюсы и решил серьёзно заняться реализацией, которая мне казалась не совсем тривиальной.
Что это за ориентированность на компоненты? Объекты уже не в тренде?
Представьте, что любой сложный объект вашей системы выглядит как набор более мелких, каждый из которых умеет выполнять свою и только свою задачу, но вместе эта симфония компонентов порождает именно то поведение, которое нужно. Всё, что остаётся, — сконструировать все сложные сущности и запустить приложение.
Вероятно, именно поэтому так громко сказано: "компонентно-ориентированное программирование", ведь объект — это уже набор не данных (полей) и поведения (методов), а компонентов — других объектов.
Чем эта идея показалась привлекательной?
Во-первых, любой компонент решает одну задачу — тут даже без знания про Single Responsibility Principle (SOLID) интуитивно понятно, что это хорошо.
Во-вторых, компоненты независимы друг от друга: каждый знает только то, что он делает, и что ему для этого нужно. Такой компонент легко сопровождать, изменять, переиспользовать и тестировать.
Возможно, я романтик, но этих двух позиций было достаточно, чтобы с головой углубиться в продумывание того, как можно реализовать подобное решение.
Если взять за основу правило о том, что каждый компонент, присоединённый к объекту-контейнеру, должен так или иначе влиять на возможное поведение последнего, возникает несколько проблем: как любой клиентский код, глядя на этот абстрактный контейнер, узнает о наборе компонентов, которые там находятся?
Другой нюанс: необходимо предусмотреть возможность того, что одному компоненту понадобится «общение» с другим, если они оба находятся на одном контейнере.
Начнём
Следуя правилам здравого смысла, необходимо попытаться как можно абстрактнее описать процесс того, как будет работать каждый из элементов будущей системы. Самое простое следствие, это выделение двух абстрактных сущностей:
- "компонент" — абстрактная функциональная единица системы;
- "контейнер компонентов" — объект, умеющий хранить в себе набор компонентов.
Мне очень нравится любые сложные вещи прорабатывать на максимально простых и интуитивных примерах, поэтому давайте попробуем представить очень простую сущность «Игрок» в традиционном объектном стиле:
public class Player
{
public int Health; // Здоровье.
public int Mana; // Мана.
public int Strength; // Сила.
public int Agility; // Ловкость.
public int Intellect; // Интеллект.
public WeaponSlot WeaponSlot; // Слот для оружия.
}
Теперь покажу как хотелось бы видеть это всё в компонентном стиле:
// Класс игрока теперь является контейнером компонентов.
public class Player : ComponentContainer
{
// Какая-то специфическая логика, хотя не обязательно.
}
// Компонент базовых характеристик.
public class BaseStats : Component
{
public int Health; // Здоровье.
public int Mana; // Мана.
}
// Компонент характеристик игровых персонажей.
public class CreatureStats : Component
{
public int Strength; // Сила.
public int Agility; // Ловкость.
public int Intellect; // Интеллект.
}
// Компонент слота для оружия.
public class WeaponSlot : Component
{
public Weapon Weapon; // Оружие.
}
Как видите, ни один из компонентов понятия не имеет о других, а также о самом игроке. Как в таком случае мы его создаём?
// Код какой-то фабрики или любого другого объекта.
public Player CreatePlayer()
{
var player = new Player(); // или new ComponentContainer();
player.AddComponent<BaseStats>();
player.AddComponent<CreatureStats>();
player.AddComponent<WeaponSlot>();
return player;
}
Если эффективность дизайна системы проявляется именно тогда, когда поступают изменения, то с текущей версией у нас будут проблемы, если попытаться рассмотреть более сложный пример: взаимодействие игрока с не игровым персонажем (NPC).
Итак, контекст проблемы следующий: в какой-то момент игрок кликает по модели NPC (или нажимает кнопку на клавиатуре) и активирует вызов диалогового окна. Необходимо отобразить все задания, которые доступны игроку на данный момент с учётом ограничений по уровню.
Попытаюсь набросать краткий набросок того, как это будет выглядеть:
// ... код открытия диалогового окна.
// Как-то получаем ссылки на "действующих лиц" - двух контейнеров.
var player = GetPlayer();
var questGiverNpc = GetQuestGiver();
var playerStats = player.GetComponent<StatsComponent>();
if (playerStats == null) return;
// Игрок не может брать задания у этого персонажа, если его уровень меньше 10.
if (playerStats.Level < 10) return;
var questList = questGiverNpc.GetComponent<QuestList>();
if (questList == null) return;
// Заберём все доступные игроку задания.
var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);
// ... какие-то манипуляции с этими заданиями.
Как видите, с учётом того, что мы не знаем (и не хотим знать) ничего о содержимом контейнеров, единственный способ — это попытаться достать соответствующие компоненты. Порой, достаточно и такого решения, но мне хотелось пойти дальше и понять, что ещё с этим можно сделать и как преобразить, чтобы превратить в очень удобную модель.
Первый шаг: вынести взаимодействие объектов-контейнеров в отдельный слой. Таким образом появляется абстракция Interactor (от англ. interaction — взаимодействие, следовательно, interactor — тот, кто взаимодействует).
При разработке любой системы, я люблю представлять, как будет выглядеть код самого верхнего уровня, иными словами: «Если это фреймворк, то как с ним будет работать конечный пользователь?»
Возьмём за основу код прошлого примера с заданиями:
// ... игрок попытался поговорить с NPC.
var player = GetPlayer();
var questGiver = GetQuestGiver();
player.Interact(questGiver).Using<QuestDialogInteractor>();
Вся интрига досталась классу QuestDialogInteractor. Как его организовать для магического достижения результата? Покажу самый простой и очевидный, опять же на основе предыдущего примера:
public class QuestDialogInteractor : Interactor
{
public void Interact(ComponentContainer a, ComponentContainer b)
{
var player = a as Player;
if (player == null) return;
var questGiver = b as QuestGiver;
if (questGiver == null) return;
var playerStats = player.GetComponent<StatsComponent>();
if (playerStats == null) return;
if (playerStats.Level < 10) return;
var questList = questGiverNpc.GetComponent<QuestList>();
if (questList == null) return;
var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);
// Манипуляции с заданиями.
}
}
Почти сразу ясно, что текущая реализация ужасна. Во-первых, мы полностью подвязались под проверку:
if (playerStats.Level < 10)
Один персонаж выдаёт задачи для 5-го уровня, другой для 27-го и т.д. Во-вторых, самый серьёзный прокол: есть зависимость от типов Player и QuestGiver. Их можно заменить на ComponentContainer, но что, если мне таки понадобятся ссылки на конкретные типы? А понадобились на эти типы, понадобятся и на другие. Любое изменение приведёт к нарушению Open/Closed Principle (SOLID).
Рефлексия
Решение нашлось в механизме мета-данных, предлагаемом в .NET, который позволяет ввести ряд правил и ограничений.
Общая идея заключается в том, чтобы иметь возможность определять методы в наследнике типа Interactor, которые принимают два параметра с типами, производными от ComponentContainer. Такой метод не будет является дееспособным, если не пометить его атрибутом [InteractionMethod].
Таким образом, предыдущий код превращается в интуитивно-понятный:
public class QuestDialogInteractor : Interactor
{
[InteractionMethod]
public void PlayerAndQuestGiver(Player player, QuestGiver questGiver)
{
var playerStats = player.GetComponent<StatsComponent>();
if (playerStats == null) return;
if (playerStats.Level < 10) return;
var questList = questGiverNpc.GetComponent<QuestList>();
if (questList == null) return;
var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);
// Манипуляции с заданиями.
}
}
Всё ещё бросаются в глаза эти «попытки» достать компонент из контейнера, которые хотелось бы куда-то убрать.
C помощью того же инструмента, вводим дополнительный контракт в виде атрибута [RequiresComponent(parameterName, ComponentType)]:
public class QuestDialogInteractor : Interactor
{
[InteractionMethod]
[RequiresComponent("player", typeof(StatsComponent))]
[RequiresComponent("questGiver", typeof(QuestList))]
public void PlayerAndQuestGiver(Player player, QuestGiver questGiver)
{
var playerStats = player.GetComponent<StatsComponent>();
if (playerStats.Level < 10) return;
var questList = questGiverNpc.GetComponent<QuestList>();
var availableQuests = questList.Where(quest => quest.Level >= playerStats.Level);
// Манипуляции с заданиями.
}
}
Вот теперь всё выглядит чистым и аккуратным. Что изменилось с первоначального варианта:
- добавили слой Interaction;
- добавили правила и ограничения.
Кроме взаимодействия двух объектов-контейнеров была проблема с тем, как обеспечить взаимодействие между несколькими компонентами внутри одного контейнера. Для решения этой задачи я использовал похожий с предыдущим подход: когда пользователь добавляет компонент на контейнер, тот вызывает у первого метод-обработчик, передавая в качестве параметра самого себя:
public class ComponentContainer
{
public void AddComponent(Component component)
{
// код... Добавляем, кешируем.
component.OnAttach(this);
}
}
Метод OnAttach в свою очередь находит у конкретного типа компонента (с помощью рефлексии и полиморфизма) метод, помеченный атрибутом [AttachHandler], который умеет работать с конкретным типом контейнера.
В случае, если такому компоненту для работы необходимо наличие некоторых других компонентов контейнера, его класс можно пометить тем же атрибутом [RequiresComponent(ComponentType)].
Рассмотрим на примере компонента, задачей которого является рисование текстуры с помощью библиотеки XNA:
[RequiresComponent(typeof(PositionComponent))]
[RequiresComponent(typeof(SpriteBatch))]
public class TextureDrawComponent : Component
{
[AttachHandler]
public void OnTextureHolderAttach(ITexture2DHolder textureHolder)
{
// В интерфейсе ITexture2DHolder нет метода GetComponent, но
// он есть в базовом ComponentContainer, который сперва приходит в родитель Component,
// поэтому можно сделать protected метод GetComponent для всех наследников Component.
var spriteBatch = GetComponent<SpriteBatch>();
spriteBatch.Draw(textureHolder.Texture2D, GetComponent<Position>(), Color.White);
}
}
Напоследок я бы хотел привести ещё парочку очень простых примеров взаимодействия:
// Игрок атакует монстра.
player.Interact(monster).Using<AttackInteractor>();
// Игрок использует зелье лечения.
player.Interact(healthPotion).Using<ItemUsingInteractor>();
// Игрок подбирает предметы с убитого монстра.
player.Interact(monster).Using<LootInteractor>();
Итоги
Пока что система готова не полностью: есть несколько окон для расширения, оптимизации (всё-таки активно используется рефлексия, надо не поскупиться на кеширование), необходимо более тщательно продумать взаимодействие ОЧЕНЬ сложных сущностей.
Хотелось бы добавить в атрибут [RequiresComponent] вариант поведения в случае, если код не соответствует контракту (игнорировать или бросать исключение).
Вполне вероятно, что, в конец концов, эта «гонка» за расширяемостью и удобством не приведёт ни к чему хорошему, но, во всяком случае, будет неоценимый опыт.
Сам подход я назвал CIM — Component Interactor Model, и планирую тщательно проверить его на работоспособность в ближайших «домашних» проектах. Если тема кого-то заинтересует, в следующей части могу рассмотреть source-code таких классов как Component, ComponentContainer, реализация, связанная с Using<T> и Interactor.
Спасибо за внимание!
Комментарии (24)
Alvaro
13.11.2015 15:47+2Подход хорош, если его не использовать бездумно. То есть исключительно для задания возможных динамических возможностей. Для всего остального контракт гораздо понятнее задаётся явным интерфейсом. Например, у вас есть Player и судя по вашей логике он теоретически может жить без маны и здоровья. Это нехорошо(как я думаю).
JoshuaLight
13.11.2015 21:57К счастью, мы можем реализовать ковариантность методов [InteractionMethod] и [AttachHandler] как для типов-наследников ComponentContainer, так и для интерфейсов, которые они реализуют. Например, в месте, где я описываю компонент TextureDrawComponent, в качестве параметра приходит абстракция ITexture2DHolder, хотя, по факту, компонент присоединяется к типу XnaLifecycleClient, который является дочерним по отношению к ComponentContainer.
Насчёт маны и здоровья — согласен, присутствие некоторых инвариантных характеристик объектов, которые не изменяются, крайне необходимо.
caballero
13.11.2015 16:07+1Выглядит как ООП с паттерном строитель не более того. Мне кажется вместо рефлексии таки проще использовать интерфейсы. Ну и события — компонент который хочет знать об изменениях по соседству подписывается на уведомления — в простейшем случае обычный делегат.
Ну и немаловажное — как правило компоненты применяются в системах связанных с визуализацией оных. То есть, очевидно что каждый компонент должен обеспечить свой рендеринг. А значит нужно и взаимодействие с контейнером — где именно, в каком месте, будет себя рендерить компонент.
просто сам в свое время разработал компонентный-событийно ориентированный движок и сталкивался с подобными вопросами.
.
ATOMOHOD
15.11.2015 02:09поддерживаю идею с ивентами. Механизм-то идеально подойдет для оповещения входящих в контейнер компонент о необходимости отреагировать
fogone
13.11.2015 16:50А чем плохо просто вот так?
ItemUsingInteractor.interact(player, heathPotion);
JoshuaLight
13.11.2015 22:11Хороший вариант, который я возьму на заметку и попробую использовать в случае, если не смогу никак расширить то, что уже сейчас есть, связанное с классом Interactor.
Ваш пример менее очевиден в том плане, что сходу я не понимаю, как это использовать, если не увижу похожий случай где-то в коде. Когда человек пишет что-то вроде:
player.Interact(healthPotion);
ему интуитивно понятно, что с чем взаимодействует. С другой стороны, когда мы используем метод Using<T>, IDE подсказывает, какие наследники Interactor могут пригодиться. Однако, здесь кроется недочёт: что будет, если не вызвать этот метод Using? Пользователь со стороны, может, и вовсе не знать о существовании оного.fogone
14.11.2015 12:39Просто не совсем понятно, зачем нужен рантайм механизм с аннотациями, когда язык умеет сматчить нужный метод без всяких проблем и проверить на уровне компиляции. Оно безусловно здорово выглядит и очень гибко, но по функциональности, как я понял, ничем от вызова статического метода не отличается.
player.Interact(healthPotion).Using<ItemUsingInteractor>();
по производимому действию идентичен
ItemUsingInteractor.interact(player, heathPotion);
не нужна аннотация [InteractionMethod], аннотации, которые относятся к параметрам, думаю, лучше было бы повесить на сами параметры, аля:
public static void PlayerAndQuestGiver( [RequiresComponent(typeof(StatsComponent))] Player player, [RequiresComponent(typeof(QuestList))] QuestGiver questGiver)
Однако, проверять это контракт в рантайме для игры несколько накладно. Да и нужно ли это, если он всё равно вывалится при попытке найти компонент?JoshuaLight
14.11.2015 19:38Совершенно верно! Оба этих варианта идентичны, но в первом случае есть накладные расходы на рефлексию и дополнительные проверки, от которых, вероятно, со временем придётся отказаться.
Насчёт атрибутов к параметрам — спасибо, совсем забыл за это.
Моя цель — сделать так, чтобы получилась гибкая и расширяемая система, которая будет «не сильно» медленной, но очень простой и понятной. Чтобы код можно было легко читать, править, тестировать. Если получится, что я добьюсь понятного и дружелюбного способа написания логики в ущерб каким-то ресурсам: ничего, подумаем, как оптимизировать.
P.S. Спасибо. Очень хорошо, что решил написать сюда статью, потому что получил массу полезных комментариев и отзывов, и это очень поможет в будущей разработке. Планирую вторую часть статьи, где расскажу про изменения в движке, расширения и прочие интересные штуки.fogone
14.11.2015 20:29Думаю, самый понятный способ взаимодействия был бы такой: player.Heal(healthPotion.Value); а самый гибкий: событийный
AndreyDmitriev
13.11.2015 19:27Любопытно, а WCF вы не рассматривали как возможный путь для организации общения между компонентами (если их обозвать «сервисами»)?
AlexanderG
13.11.2015 23:31Иногда так и реализуют. Компоненты в этом случае просто хранят данные, а обрабатываются специальными для каждого типа компонента «подсистемами». Соответственно, подсистемы stateless и в этом повторяют сервисы.
ATOMOHOD
15.11.2015 02:05Метод OnAttach в свою очередь находит у конкретного типа компонента (с помощью рефлексии и полиморфизма) метод, помеченный атрибутом [AttachHandler], который умеет работать с конкретным типом контейнера.
я не очень понял, а зачем искать помеченный метод, если в конкретном классе компонента будет известно какой метод какой тип контейнера может обрабатывать. Паттерн Visitor же…JoshuaLight
15.11.2015 13:39Паттерн Visitor предполагает, грубо говоря, отношение «многие ко многим» между семейством различных посетителей и множеством разнородных объектов. То есть, пришлось бы определять перегрузки метода OnAttach в типе Component для множества конкретных типов контейнеров компонентов, если таковые есть.
В моём же случае отношение получается «почти» 1к1, а иногда менее важно, какой приходит контейнер, чем компоненты, в нём находящиеся. Изначально это могло выглядеть так:
public class HealthPotion : Component { // Отмечу, что мы переопределили метод из базового класса. public override void OnAttach(ComponentContainer container) { var player = container as Player; if (player == null) return; // или throw; player.Inventory.AddComponent(this); } }
И этот вариант, в принципе, неплох. Но давайте посмотрим внимательнее: видно, что затем этот же компонент добавляется на контейнер типа Inventory. Это означает, что в классе (HealthPotion) необходимо обработать OnAttach на инвентарь. Конечно, пример не самый удачный в том плане, что такие ситуации уже умеет разруливать слой Interaction, тем не менее, как по мне, код «попахивает».
Чтобы избежать всех подобных вещей, я решил, что надо сделать так, чтоб каждый компонент работал только с тем контейнером, с которым он умеет работать. Уйти от этих злополучных попыток каста с помощью as. В общем, больше уверенности!
Тогда предыдущий пример превращается в что-то более интуитивное и понятное (опять же, это всего лишь пример):
public class HealthPotion : Component { [AttachHandler] public void OnPlayerAttach(Player player) { player.Inventory.AddComponent(this); } [AttachHandler] public void OnInventoryAttach(Inventory inventory) { inventory.Items.Add(this); } }
ATOMOHOD
15.11.2015 21:26тобы избежать всех подобных вещей, я решил, что надо сделать так, чтоб каждый компонент работал только с тем контейнером, с которым он умеет работать. Уйти от этих злополучных попыток каста с помощью as. В общем, больше уверенности!
ну вы не ушли от них. При вызове метода все равно же рефлексией смотрите какой метод надо вызвать в зависимотси от типа параметров, верно?
ATOMOHOD
15.11.2015 22:01+1public class Program { static void Main() { var component = new Component() as BaseComponent; //factory var containerA = new ContainerA() as BaseContainer; //factory var containerB = new ContainerB() as BaseContainer; //factory containerA.AddComponent(component); containerB.AddComponent(component); } } public abstract class BaseContainer { public virtual void AddComponent<T>(ICanHandle<T> component) where T : BaseContainer { var handle = component as ICanHandle<BaseContainer>; if (handle == null) throw new Exception(); handle.OnAttach(this); } } public class ContainerA : BaseContainer { public override void AddComponent<T>(ICanHandle<T> component) { var handle = component as ICanHandle<ContainerA>; if (handle == null) { base.AddComponent(component); return; } handle.OnAttach(this); } } public class ContainerB : BaseContainer { public override void AddComponent<T>(ICanHandle<T> component) { var handle = component as ICanHandle<ContainerB>; if (handle == null) { base.AddComponent(component); return; } handle.OnAttach(this); } } public abstract class BaseComponent : ICanHandle<BaseContainer> { public abstract void OnAttach(BaseContainer container); } public interface ICanHandle<in T> where T : BaseContainer { void OnAttach(T container); } public class Component : BaseComponent, ICanHandle<ContainerA>, ICanHandle<ContainerB> { public override void OnAttach(BaseContainer container) { } public void OnAttach(ContainerA container) { } public void OnAttach(ContainerB container) { } }
Вот такой вариант мне нравится больше. Тут и читаемость есть, и полная управляемость и необходимая гибкость.JoshuaLight
16.11.2015 00:03Спасибо за ответ.
Вы правы, проверки я скрыл в базовых типах, оставив клиентам только что-то очень простое.
Мне тоже ваш вариант понравился, наверное, лучше, чем мой, но в нём есть два нюанса: дублирование кода и не очевидное наследование. Каждый контейнер, по сути, должен переопределить метод OnAttach и сделать одно и то же. Если бы я был клиентом этого «фреймворка», мне такое пришлось бы не по духу.
Смотрите, когда я решил использовать рефлексию и атрибуты, мне хотелось максимальной лёгкости. Сейчас это выглядит так: «унаследовался, написал метод». Это могло быть «унаследовался, написал метод, закастил». Лучше, вообще, «написал метод». Идеально.
Да, там, скорее всего, будет проседание по скорости, но сейчас ещё очень рано думать об оптимизации.
shai_hulud
thelinuxlich.github.io/artemis_CSharp Альтернатива
JoshuaLight
Большое спасибо, обязательно ознакомлюсь с их идеями, очень полезно будет.
AlexanderG
Еще одна: github.com/jhauberg/ComponentKit