В команде, большая часть серверных фич пишется с помощью стейт машин.
Стейт машина позволяет выразить окружающее состояние и то как с объектом можно взаимодействовать предельно конкретно. Хорошо составленный стейт граф позволяют понять как работает фича без глубокого погружения в код. При прохождении code review большое внимание уделяется составленному графу состояний. Часто возникает потребность в чтение или модификации уже существующего кода. Отсюда возникает потребность в ускорении процесса восстановления контекста или понимании как работает код.
На помощь приходит статический анализ кода.
Привет! Меня зовут Игорь, я занимаюсь разработкой на Unity c 2018 года.
В этой статье расскажу, как с помощью Roslyn можно автоматизировать задачи.
В этой статье не будет подробного описания что такое Roslyn и его возможностей.
По теме Roslyn приведу несколько ссылок в конце статьи.
Вместо этого расскажу как решалась конкретная задача.
Весь исходный код будет доступен по ссылке на GitHub.
Краткое описание стейт машины
Имеется стейт машина (исходный код). В подробности как она работает вдаваться не будем, это не так важно. Важно понимать что существуют такие типы как State и Transition.
State - описывает состояние модуля, что модуль в этот момент делает и какие действия можно совершать в этом стейте (public методы).
Transition - осуществляет выполнение процесса перехода из одного состояния в другое, выполняя для этого необходимую логику. Причём внутри transition определяется в какой следующий State будет осуществлён переход. Transition может быть с контекстом и без.

Пример кода стейта и транзишена
class State : BaseState
{
class Transition : BaseTransition
{
protected override ExecuteResult Execute()
{
return new ExecuteResult { nextState = new State() }; // Determinate next state logic
}
}
protected override void OnEnter()
{
// Subscribtions
}
protected override void OnExit()
{
// Unsubscribtions
}
public void ExternalInteraction()
{
Leave(new Transition());
}
}
Задача
Задача заключается в автоматизировании визуализации связей между состояниями. Задачу можно разделить на следующие этапы:
нахождение всех возможных вариантов стейтов и транзишенов
определение какие транзишены используют стейты для перехода
определение в какие стейты переходят транзишены
визуализация результата
Варианты решений:
Рефлексия. Можно попытаться анализировать код через рефлексию получая
MethodInfoу класса(typeof(X).GetMethods()), а дальше доставать локальные переменныеMethodInfo.GetMethodBody().LocalVariables. Но это не будет работать, если создаваемый объект не присваивается в локальную переменную. Рефлексия отпадает.Ручной анализ текста. Можно попытаться вручную анализировать исходный текст .cs файлов, осуществляя поиск по конструкции
new ИмяКласса(). Но и тут возникают нюансы т.к. имя класса может не совпадать с оригиналом (например, если используетсяAlias). В таком случае потребуется изобретать велосипеды для анализа alias конструкций.Статический анализ кода. Можно взять исходный текст
.csфайлов, распарсить в семантическое кодовое дерево и анализировать конструкции языка c#, работая с точными типами объектов. Тут на помощь приходят библиотеки статического анализа кодаMicrosoft.CodeAnalysisявляющиеся частью платформы компиляции кода .Net или более известную какRoslyn.
Выбор решения пал на статический анализ кода.
Статический анализ кода
.NET Compiler Platform (кодовое название Roslyn) — платформа с открытым исходным кодом, содержащая компиляторы и средства для статического анализа кода, написанного на языках C# и Visual Basic (VB.NET) от Microsoft.
Источник: Wikipedia. "Roslyn"
Пока выполнял задачу, часто встречал информации про кодогенерацию, включая заметки в официальной документации Unity по анализу кода.
Документация Unity: "Roslyn analyzers and source generators"
Анализ кода и кодогенерация это две разные задачи, имейте это ввиду.
Для работы с Roslyn в Unity необходимо подключить библиотеки Microsoft.CodeAnalysis. Библиотеки не доступны через стандартный Package Manager в Unity. Так что для установки воспользуемся NuGet пакетом. NuGet тоже не поддерживается Unity. Есть сторонние решение, позволяющие интегрировать NuGet пакеты в Unity. Воспользуемся подготовленным NuGet пакетом через OpenUPM (инструкция как настроить). После этого в стандартном Package Manager находим Microsoft.CodeAnalysis.CSharp.
Важно следить за версией Roslyn, которую поддерживает Unity. Согласно официальной документации Unity, для версии 6000.3 поддерживается библиотека Microsoft.CodeAnalysis.CSharp ver 4.3 (на момент написания статьи уже существует ver 5.0).
2.Install the Microsoft.CodeAnalysis.Csharp NuGet package for the project. Your source generator must use Microsoft.CodeAnalysis.Csharp 4.3 to work with Unity.
Источник: Unity Documentation. Create and use a source generator
P.S. Можно не подключать OpenUPM, а самостоятельно скачать Microsoft.CodeAnalysis.CSharp и сохранить локально. Главное убедиться, что все нужные зависимости и нужной версии приехали вместе с библиотекой.
Анализ кода
Для упрощения, в статье код представлен непосредственно для фичи "Match" из репозитория с исходными файлами. В репозитории используется универсальное решение.
Общая идея алгоритма:
Каждая фича для своей стейт машины должна создать базовый абстрактный класс и базовый абстрактный транзишен. Дальше от этих классов уже наследуются классы с реализацией логики конкретных стейтов и транзишенов.
С помощью рефлексии найдём все типы наследников
Match.Logic.BaseStateиMatch.Logic.BaseTransition.С помощью анализа кода найдём все места, где осуществляется создание наследников базовых типов.
По найденным местам создания объектов определим какому классу принадлежит создание экземпляра объекта.
Составим список мест создания объекта.
Код нахождения наследников Match.Logic.BaseState
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
Type[] assemblyTypes = AppDomain
.CurrentDomain
.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.ToArray();
Type[] inheritBaseStateTypes = GetInheritTypes(assemblyTypes, typeof(Match.Logic.BaseState));
}
static Type[] GetInheritTypes(Type[] types, Type baseType)
{
return types
.Where(type => type.BaseType != null && type.BaseType == baseType)
.ToArray();
}
}
Чтобы начать анализировать код, необходимо загрузить исходный код как текст. Для удобства использования, код объединяется в одну строку (не потребуется перебирать множество SyntaxNode через foreach).
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
// Previous code
string[] cSharpFilePaths = Directory.GetFiles(sourceCodePath, "*.cs", SearchOption.AllDirectories);
string sourceCode = string.Join("\n", cSharpFilePaths.Select(File.ReadAllText));
}
}
Осуществляем парсинг исходного кода. Получаем синтаксическое дерево SyntaxTree и достаём из него syntaxTree.GetRoot() самый верхний узел SyntaxNode. SyntaxNode - это базовый тип узлов синтаксического дерева. От него уже наследуются все узлы описывающие конструкции и выражения в C#. Конкретно нас интересует выражение вызова созданий экземпляра объекта ObjectCreationExpressionSyntax. Через root узел достаём все дочерние узлы syntaxRoot.DescendantNodes() и отфильтровываем все ObjectCreationExpressionSyntax.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
// Previous code
string[] cSharpFilePaths = Directory.GetFiles(sourceCodePath, "*.cs", SearchOption.AllDirectories);
string sourceCode = string.Join("\n", cSharpFilePaths.Select(File.ReadAllText));
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
SyntaxNode syntaxRoot = syntaxTree.GetRoot();
ObjectCreationExpressionSyntax[] objectCreationExpressionSyntaxes = syntaxRoot
.DescendantNodes()
.OfType<ObjectCreationExpressionSyntax>()
.ToArray();
}
}
Дальше необходимо определить какие из этих объектов ObjectCreationExpressionSyntax являются искомыми типами (стейтами или транзишенами). Для этого строится семантическая модель, но перед этим необходимо скомпилировать код.
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
// Previous code
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
ObjectCreationExpressionSyntax[] objectCreationExpressionSyntaxes = syntaxRoot
.DescendantNodes()
.OfType<ObjectCreationExpressionSyntax>()
.ToArray();
var compilation = CSharpCompilation.Create("MyCompilation", new[] { syntaxTree });
SemanticModel semanticModel = compilation.GetSemanticModel(syntaxTree);
}
}
Теперь с помощью SemanticModel можно получить определение типа, которое в анализе кода называется ISymbol. ISymbol называются объекты, которые предоставляются компилятором (namespace, class, method, parameter и др). Для анализа преобразуем полученные ранее inheritBaseStateTypes в список ISymbol. Переберём objectCreationExpressionSyntaxes и преобразуем в ISymbol. Если полученный символ от objectCreationExpressionSyntax встречается в списке искомых стейтов, то осуществляем рекурсивный поиск ClassDeclarationSyntax вверх по синтаксическому дереву для определения в каком классе происходит создание объекта и кэшируем.
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
// Previous code
ObjectCreationExpressionSyntax[] objectCreationExpressionSyntaxes = ...;
SemanticModel semanticModel = compilation.GetSemanticModel(syntaxTree);
HashSet<ISymbol> stateSymbols = inheritBaseStateTypes
.Select(t => compilation.GetTypeByMetadataName(t.FullName))
.ToHashSet(SymbolEqualityComparer.Default);
Dictionary<ISymbol, HashSet<ISymbol>> creationStateSourcesByState = stateSymbols
.ToDictionary(
symbol => symbol,
_ => new HashSet<ISymbol>(SymbolEqualityComparer.Default),
SymbolEqualityComparer.Default
);
foreach (ObjectCreationExpressionSyntax objectCreationExpressionSyntax in objectCreationExpressionSyntaxes)
{
TypeInfo typeInfo = semanticModel.GetTypeInfo(objectCreationExpressionSyntax);
ITypeSymbol typeInfoTypeSymbol = typeInfo.Type;
if (creationStateSourcesByState.ContainsKey(typeInfoTypeSymbol))
{
ClassDeclarationSyntax sourceClassDeclarationSyntax = GetClassDeclarationSyntax(objectCreationExpressionSyntax);
ISymbol sourceSymbol = semanticModel.GetDeclaredSymbol(sourceClassDeclarationSyntax);
creationStateSourcesByState[typeInfoTypeSymbol].Add(sourceSymbol);
}
}
}
static ClassDeclarationSyntax GetClassDeclarationSyntax(SyntaxNode syntaxNode)
{
return syntaxNode.Parent as ClassDeclarationSyntax ?? GetClassDeclarationSyntax(syntaxNode.Parent);
}
}
Стейты и транзишены могут быть созданы не только внутри друг друга, но и в других классах (например, за счёт фабрик или инициализирующий стейт для стейт машины). Отфильтруем такие источники для визуализации. В итоге получим два списка переходов "из транзишена в стейт" и "из другого источника в стейт".
static class CodeAnalyzer
{
public static void Analyze(string sourceCodePath)
{
// Previous code
Dictionary<ISymbol, HashSet<ISymbol>> creationStateSourcesByState = ...;
Type[] inheritBaseTransitionTypes = new[]
{
GetInheritTypes(assemblyTypes, typeof(Match.Logic.BaseTransition)),
GetInheritGenericTypes(assemblyTypes, typeof(Match.Logic.BaseTransition<>))
}
.SelectMany(t => t)
.ToArray();
HashSet<ISymbol> transitionSymbols = inheritBaseTransitionTypes
.Select(t => compilation.GetTypeByMetadataName(t.FullName))
.ToHashSet(SymbolEqualityComparer.Default);
Dictionary<ISymbol, HashSet<ISymbol>> fromTransitionToStateByState = stateSymbols
.ToDictionary(
stateSymbol => stateSymbol,
stateSymbol => creationStateSourcesByState[stateSymbol]
.Where(creationStateSource => transitionSymbols.Contains(creationStateSource))
.ToHashSet(SymbolEqualityComparer.Default),
SymbolEqualityComparer.Default
);
Dictionary<ISymbol, HashSet<ISymbol>> fromOtherSourceToStateByState = stateSymbols
.ToDictionary(
stateSymbol => stateSymbol,
stateSymbol => creationStateSourcesByState[stateSymbol]
.Where(creationStateSource => !transitionSymbols.Contains(creationStateSource))
.ToHashSet(SymbolEqualityComparer.Default),
SymbolEqualityComparer.Default
);
}
}
Для стейтов готово. Остаётся повторить процедуру для транзишенов.
Чуток под шаманим и в результате получаем такой код.
Полный код анализа
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Frameworks.StateMachine.StateGraphVisualizer
{
public static class CodeAnalyzer
{
public class Result
{
public HashSet<string> states;
public HashSet<string> transitions;
public Dictionary<string, HashSet<string>> fromTransitionToStateByState;
public Dictionary<string, HashSet<string>> fromOtherSourceToStateByState;
public Dictionary<string, HashSet<string>> fromStateToTransitionByTransition;
public Dictionary<string, HashSet<string>> fromOtherSourceToTransitionByTransition;
}
public static Result Analyze(Type[] inheritBaseStateTypes, Type[] inheritBaseTransitionTypes,
string sourceCodePath)
{
string[] cSharpFilePaths = Directory.GetFiles(sourceCodePath, "*.cs", SearchOption.AllDirectories);
string sourceCode = string.Join("\n", cSharpFilePaths.Select(File.ReadAllText));
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
SyntaxNode syntaxRoot = syntaxTree.GetRoot();
var compilation = CSharpCompilation.Create("MyCompilation", new[] { syntaxTree });
SemanticModel semanticModel = compilation.GetSemanticModel(syntaxTree);
ObjectCreationExpressionSyntax[] objectCreationExpressionSyntaxes = syntaxRoot
.DescendantNodes()
.OfType<ObjectCreationExpressionSyntax>()
.ToArray();
HashSet<ISymbol> stateSymbols = inheritBaseStateTypes
.Select(t => compilation.GetTypeByMetadataName(t.FullName))
.ToHashSet(SymbolEqualityComparer.Default);
HashSet<ISymbol> transitionSymbols = inheritBaseTransitionTypes
.Select(t => compilation.GetTypeByMetadataName(t.FullName))
.ToHashSet(SymbolEqualityComparer.Default);
Dictionary<ISymbol, HashSet<ISymbol>> creationStateSourcesByState =
GetCreationSymbolSourcesBySymbol(semanticModel, objectCreationExpressionSyntaxes, stateSymbols);
Dictionary<ISymbol, HashSet<ISymbol>> creationTransitionSourcesByTransition =
GetCreationSymbolSourcesBySymbol(semanticModel, objectCreationExpressionSyntaxes, transitionSymbols);
Dictionary<ISymbol, HashSet<ISymbol>> fromTransitionToStateByState = FilterValuesByPredicate(
targetSymbols: stateSymbols,
allSymbolSourcesByTargetSymbol: creationStateSourcesByState,
allSymbolSourcesFilter: symbol => transitionSymbols.Contains(symbol)
);
Dictionary<ISymbol, HashSet<ISymbol>> fromOtherSourceToStateByState = FilterValuesByPredicate(
targetSymbols: stateSymbols,
allSymbolSourcesByTargetSymbol: creationStateSourcesByState,
allSymbolSourcesFilter: symbol => !transitionSymbols.Contains(symbol)
);
Dictionary<ISymbol, HashSet<ISymbol>> fromStateToTransitionByTransition = FilterValuesByPredicate(
targetSymbols: transitionSymbols,
allSymbolSourcesByTargetSymbol: creationTransitionSourcesByTransition,
allSymbolSourcesFilter: symbol => stateSymbols.Contains(symbol)
);
Dictionary<ISymbol, HashSet<ISymbol>> fromOtherSourceToTransitionByTransition = FilterValuesByPredicate(
targetSymbols: transitionSymbols,
allSymbolSourcesByTargetSymbol: creationTransitionSourcesByTransition,
allSymbolSourcesFilter: symbol => !stateSymbols.Contains(symbol)
);
return new Result
{
states = ConvertSymbolsToNames(stateSymbols),
transitions = ConvertSymbolsToNames(transitionSymbols),
fromTransitionToStateByState = ConvertSymbolsToNames(fromTransitionToStateByState),
fromOtherSourceToStateByState = ConvertSymbolsToNames(fromOtherSourceToStateByState),
fromStateToTransitionByTransition = ConvertSymbolsToNames(fromStateToTransitionByTransition),
fromOtherSourceToTransitionByTransition = ConvertSymbolsToNames(fromOtherSourceToTransitionByTransition)
};
}
static Dictionary<ISymbol, HashSet<ISymbol>> FilterValuesByPredicate(HashSet<ISymbol> targetSymbols,
Dictionary<ISymbol, HashSet<ISymbol>> allSymbolSourcesByTargetSymbol,
Func<ISymbol, bool> allSymbolSourcesFilter)
{
return targetSymbols
.ToDictionary(
targetSymbol => targetSymbol,
targetSymbol => allSymbolSourcesByTargetSymbol[targetSymbol]
.Where(allSymbolSourcesFilter)
.ToHashSet(SymbolEqualityComparer.Default),
SymbolEqualityComparer.Default
);
}
static Dictionary<string, HashSet<string>> ConvertSymbolsToNames(Dictionary<ISymbol, HashSet<ISymbol>> source)
{
return source.ToDictionary(
kv => GetSymbolName(kv.Key),
kv => ConvertSymbolsToNames(kv.Value)
);
}
static HashSet<string> ConvertSymbolsToNames(HashSet<ISymbol> symbols)
{
return symbols
.Select(GetSymbolName)
.ToHashSet();
}
static string GetSymbolName(ISymbol symbol)
{
return symbol.ToDisplayString();
}
static Dictionary<ISymbol, HashSet<ISymbol>> GetCreationSymbolSourcesBySymbol(SemanticModel semanticModel,
ObjectCreationExpressionSyntax[] objectCreationExpressionSyntaxes,
HashSet<ISymbol> creationSymbols)
{
Dictionary<ISymbol, HashSet<ISymbol>> result = creationSymbols
.ToDictionary(
symbol => symbol,
_ => new HashSet<ISymbol>(SymbolEqualityComparer.Default),
SymbolEqualityComparer.Default
);
foreach (ObjectCreationExpressionSyntax objectCreationExpressionSyntax in objectCreationExpressionSyntaxes)
{
TypeInfo typeInfo = semanticModel.GetTypeInfo(objectCreationExpressionSyntax);
ITypeSymbol typeInfoTypeSymbol = typeInfo.Type;
if (result.TryGetValue(typeInfoTypeSymbol, out HashSet<ISymbol> creationSymbolSources))
{
ClassDeclarationSyntax sourceClassDeclarationSyntax =
GetClassDeclarationSyntax(objectCreationExpressionSyntax);
ISymbol sourceSymbol = semanticModel.GetDeclaredSymbol(sourceClassDeclarationSyntax);
creationSymbolSources.Add(sourceSymbol);
}
}
return result;
}
static ClassDeclarationSyntax GetClassDeclarationSyntax(SyntaxNode syntaxNode)
{
return syntaxNode.Parent as ClassDeclarationSyntax ?? GetClassDeclarationSyntax(syntaxNode.Parent);
}
}
}
На выходе получаем:
список всех стейтов
список всех транзишенов
словарь переходов из стейтов в транзишены
словарь переходов из неизвестных источников в транзишены
словарь переходов из транзишенов в стейты
словарь переходов из неизвестных источников в стейты
в качестве названия используется полное имя класса (аналогично операции type(XClass).FullName)
Готово. Осталось это дело визуализировать.
Визуализация
Для визуализации воспользуемся Graphviz (Wikipedia. Graph Visualization Software).
Сгенерим исходный текст и визуализируем онлайн редактором.
Сделаем два варианта генерации:
с отображением через какие транзишены осуществляется переход (
State → Transition → State)без отображения транзишенов (
State → State)
Правила семантики для Graphviz описывать не стану, почитайте документацию потыкайте примеры в онлайн редакторах. Подробно останавливаться на том как генерируется семантика тоже не стану, опишу несколько моментов:
После выполнение код анализа получено полное названия объектов, т.е. туда входит namespace + название класса. В качестве названия стейт графа используется общая подстрока названий объектов.
В качестве названия узлов используется название класса (от FullName объекта извлекается часть после последней точки).
В качестве id узла для семантики Graphviz оставлен FullName т.к. имена транзишенов могут повторяться.
Исходный код генератора графа "State → State"
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Frameworks.StateMachine.StateGraphVisualizer.Graphviz
{
static class StateGraphGenerator
{
public static string GenerateStateGraph(CodeAnalyzer.Result codeAnalyzeResult, string label)
{
HashSet<string> stateIds = codeAnalyzeResult.states;
Dictionary<string, HashSet<string>> fromTransitionToStateByState =
codeAnalyzeResult.fromTransitionToStateByState;
Dictionary<string, HashSet<string>> fromStateToTransitionByTransition =
codeAnalyzeResult.fromStateToTransitionByTransition;
Dictionary<string, HashSet<string>> fromOtherSourceToStateByState =
codeAnalyzeResult.fromOtherSourceToStateByState;
var stringBuilder = new StringBuilder();
foreach (string stateId in stateIds)
{
stringBuilder.AppendLine(
GraphvizFormatter.FormatNode(
nodeId: stateId,
color: GraphvizFormatter.Color.Lightgrey,
nodeLabel: SourceNameHelper.GetSourceName(stateId)
)
);
}
foreach ((string toStateId, HashSet<string> fromTransitionIds) in fromTransitionToStateByState)
{
foreach (string fromTransitionId in fromTransitionIds)
{
if (fromStateToTransitionByTransition.TryGetValue(fromTransitionId,
out HashSet<string> fromStateIds))
{
foreach (string fromStateId in fromStateIds)
{
stringBuilder.AppendLine(GraphvizFormatter.JoinNodes(fromNodeId: fromStateId,
toNodeId: toStateId));
}
}
}
}
HashSet<string> toStateOtherSourceIds = fromOtherSourceToStateByState
.SelectMany(kv => kv.Value)
.ToHashSet();
foreach (string toStateOtherSourceId in toStateOtherSourceIds)
{
stringBuilder.AppendLine(
GraphvizFormatter.FormatNode(
nodeId: toStateOtherSourceId,
nodeLabel: SourceNameHelper.GetSourceName(toStateOtherSourceId),
color: GraphvizFormatter.Color.Yellow
)
);
}
foreach ((string stateId, HashSet<string> otherSourceIds) in fromOtherSourceToStateByState)
{
foreach (string otherSourceId in otherSourceIds)
{
stringBuilder.AppendLine(GraphvizFormatter.JoinNodes(fromNodeId: otherSourceId,
toNodeId: stateId));
}
}
string withTitleContainerNode =
GraphvizFormatter.FormatSubgraph("Container", label, nodes: $"{stringBuilder}");
return GraphvizFormatter.FormatDigraph(id: "Root", "", withTitleContainerNode);
}
}
}
Исходный код генератора графа "State → Transition → State"
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Frameworks.StateMachine.StateGraphVisualizer.Graphviz
{
static class StateGraphWithTransitionsGenerator
{
public static string Generate(CodeAnalyzer.Result codeAnalyzeResult, string label)
{
HashSet<string> stateIds = codeAnalyzeResult.states;
HashSet<string> transitionIds = codeAnalyzeResult.transitions;
Dictionary<string, HashSet<string>> fromStateToTransitionByTransition = codeAnalyzeResult
.fromStateToTransitionByTransition;
Dictionary<string, HashSet<string>> fromOtherSourceToTransitionByTransition = codeAnalyzeResult
.fromOtherSourceToTransitionByTransition;
Dictionary<string, HashSet<string>> fromTransitionToStateByState =
codeAnalyzeResult.fromTransitionToStateByState;
Dictionary<string, HashSet<string>> fromOtherSourceToStateByState =
codeAnalyzeResult.fromOtherSourceToStateByState;
var fromStateToTransitionByState = new Dictionary<string, HashSet<string>>();
foreach ((string toTransitionId, HashSet<string> fromStateIds) in fromStateToTransitionByTransition)
{
foreach (string fromStateId in fromStateIds)
{
if (fromStateToTransitionByState.TryGetValue(fromStateId, out HashSet<string> value))
{
value.Add(toTransitionId);
}
else
{
fromStateToTransitionByState.Add(fromStateId, new HashSet<string>(new[] { toTransitionId }));
}
}
}
var stringBuilder = new StringBuilder();
var createdTransitionIds = new HashSet<string>();
foreach ((string fromStateId, HashSet<string> toTransitionIds) in fromStateToTransitionByState)
{
createdTransitionIds.UnionWith(toTransitionIds);
stringBuilder.AppendLine(CreateStateWithTransitionsNode(fromStateId, toTransitionIds));
}
foreach (string stateId in stateIds)
{
bool isStateNodeCreated = fromStateToTransitionByState.ContainsKey(stateId);
if (!isStateNodeCreated)
{
string notUsedStateNode =
CreateStateWithTransitionsNode(stateId, transitionIds: new HashSet<string>());
stringBuilder.AppendLine(notUsedStateNode);
}
}
foreach (string transitionId in transitionIds)
{
bool isTransitionNodeCreated = createdTransitionIds.Contains(transitionId);
if (!isTransitionNodeCreated)
{
string notUsedTransitionNode = CreateTransitionNode(transitionId, GraphvizFormatter.Color.Yellow);
stringBuilder.AppendLine(notUsedTransitionNode);
}
}
foreach ((string toStateId, HashSet<string> fromTransitionIds) in fromTransitionToStateByState)
{
foreach (string fromTransitionId in fromTransitionIds)
{
stringBuilder.AppendLine(GraphvizFormatter.JoinNodes(fromTransitionId, toStateId));
}
}
HashSet<string> allOtherSourceIds = new Dictionary<string, HashSet<string>>()
.Concat(fromOtherSourceToStateByState)
.Concat(fromOtherSourceToTransitionByTransition)
.SelectMany(kv => kv.Value)
.ToHashSet();
foreach (string otherSourceId in allOtherSourceIds)
{
stringBuilder.AppendLine(
GraphvizFormatter.FormatNode(
nodeId: otherSourceId,
nodeLabel: SourceNameHelper.GetSourceName(otherSourceId),
color: GraphvizFormatter.Color.Yellow
)
);
}
foreach ((string toStateId, HashSet<string> fromOtherSourceIds) in
fromOtherSourceToStateByState)
{
foreach (string fromOtherSourceId in fromOtherSourceIds)
{
stringBuilder.AppendLine(GraphvizFormatter.JoinNodes(fromOtherSourceId, toStateId));
}
}
foreach ((string toTransitionId, HashSet<string> fromOtherSourceIds) in
fromOtherSourceToTransitionByTransition)
{
foreach (string fromOtherSourceId in fromOtherSourceIds)
{
stringBuilder.AppendLine(GraphvizFormatter.JoinNodes(fromOtherSourceId, toTransitionId));
}
}
string withTitleContainerNode =
GraphvizFormatter.FormatSubgraph("Container", label, nodes: $"{stringBuilder}");
return GraphvizFormatter.FormatDigraph(id: "Root", "", withTitleContainerNode);
}
static string CreateStateWithTransitionsNode(string stateId, HashSet<string> transitionIds)
{
var stateStringBuild = new StringBuilder();
string stateName = SourceNameHelper.GetSourceName(stateId);
stateStringBuild.AppendLine(GraphvizFormatter.FormatNode(stateId, stateName));
foreach (string transitionId in transitionIds)
{
stateStringBuild.AppendLine(CreateTransitionNode(transitionId));
stateStringBuild.AppendLine(GraphvizFormatter.JoinNodes(stateId, transitionId));
}
return GraphvizFormatter.FormatSubgraph(
id: stateName,
label: "",
color: GraphvizFormatter.Color.Lightgrey,
nodes: $"{stateStringBuild}"
);
}
static string CreateTransitionNode(string transitionId, string color = GraphvizFormatter.Color.White)
{
return GraphvizFormatter.FormatNode(
transitionId,
nodeLabel: SourceNameHelper.GetSourceName(transitionId),
color: color,
shapeType: GraphvizFormatter.ShapeType.Rect
);
}
}
}
Исходный код форматера семантики Graphviz
using System.Text;
namespace Frameworks.StateMachine.StateGraphVisualizer
{
static class GraphvizFormatter
{
public static class ShapeType
{
public const string Rect = "rect";
public const string Ellipse = "ellipse";
}
public static class Color
{
public const string Lightgrey = "lightgrey";
public const string White = "white";
public const string Red = "red";
public const string Yellow = "yellow";
}
public static class Style
{
public const string Filled = "filled";
}
public static string FormatDigraph(string id, string label, string nodes)
{
var stringBuilder = new StringBuilder();
stringBuilder.AppendLine("digraph G");
stringBuilder.AppendLine("{");
stringBuilder.AppendLine(FormatLineIndentation($"label=\"{label}\""));
stringBuilder.AppendLine(FormatLineIndentation(nodes));
stringBuilder.AppendLine("}");
return stringBuilder.ToString();
}
public static string FormatSubgraph(string id, string label, string nodes, string style = Style.Filled,
string color = Color.White)
{
var stringBuilder = new StringBuilder();
stringBuilder.AppendLine($"subgraph cluster_{id}");
stringBuilder.AppendLine("{");
stringBuilder.AppendLine(FormatLineIndentation($"style=\"{style}\""));
stringBuilder.AppendLine(FormatLineIndentation($"color=\"{color}\""));
stringBuilder.AppendLine(FormatLineIndentation($"label=\"{label}\""));
stringBuilder.AppendLine(FormatLineIndentation(nodes));
stringBuilder.AppendLine("}");
return stringBuilder.ToString();
}
public static string FormatNode(string nodeId, string nodeLabel, string shapeType = ShapeType.Ellipse,
string color = Color.White, string style = Style.Filled)
{
return $"\"{nodeId}\" [label=\"{nodeLabel}\" shape=\"{shapeType}\" color=\"{color}\" style=\"{style}\"]";
}
public static string JoinNodes(string fromNodeId, string toNodeId)
{
return $"\"{fromNodeId}\" -> \"{toNodeId}\"";
}
static string FormatLineIndentation(string text)
{
return $"\t{text.Replace("\n", "\n\t")}";
}
}
}
Если State или Transition будет создаваться не в стейтах или транзишенах (то что выше называлось как otherSources), то оно будет подсвечено цветом.
За всё время на практике не приходилось использовать фабрики для создания стейтов или транзишенов, по этому текущее решение только подсвечивает такую ситуацию. Если Вам необходимо решить это, то Вы можете написать более глубокий анализ кода, который проследит создание переменных и вызовы методов других классов.
Фиксики потрудились и забабахали окно в редакторе Unity.
Окно вызывается через Tools/StateMachine/Visualization.

Исходный код окна редактора Unity
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Frameworks.StateMachine.StateGraphVisualizer
{
public class EditorWindow : UnityEditor.EditorWindow
{
class EnumOption
{
public bool enabled;
public int selectedIndex;
public Type[] types;
public string[] typeFullNames;
}
static readonly string[] GraphvizVisualEditorUrls =
{
"https://www.devtoolsdaily.com/graphviz",
"https://graph.flyte.org"
};
Type[] _assemblyTypes;
bool _needVisualizeTransitions;
EnumOption _inheritBaseStateEnumOption;
EnumOption _inheritBaseTransitionEnumOption;
EnumOption _inheritBaseTransitionWithContextEnumOption;
string _sateGraphText;
string _sateGraphWithTransitionsText;
Vector2 _generationResultScrollPosition;
string _sourceCodeDirectoryPath;
[MenuItem("Tools/StateMachine/Visualization")]
public static void ShowWindow()
{
GetWindow<EditorWindow>();
}
void OnEnable()
{
Type baseStateType = typeof(BaseState<>);
Type baseTransitionType = typeof(BaseTransition<>);
Type baseTransitionWithContextType = typeof(BaseTransition<,>);
Type[] assemblyTypes = AppDomain
.CurrentDomain
.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.ToArray();
Type[] allAbstractClassTypes = assemblyTypes
.Where(type => type.IsClass && type.IsAbstract)
.ToArray();
_assemblyTypes = assemblyTypes;
_sourceCodeDirectoryPath = Application.dataPath;
Type[] inheritBaseStateTypes = TypesHelpers.GetInheritGenericTypes(allAbstractClassTypes, baseStateType);
_inheritBaseStateEnumOption = new EnumOption
{
enabled = true,
selectedIndex = 0,
types = inheritBaseStateTypes,
typeFullNames = inheritBaseStateTypes
.Select(t => t.FullName)
.ToArray()
};
Type[] inheritBaseTransitionTypes =
TypesHelpers.GetInheritGenericTypes(allAbstractClassTypes, baseTransitionType);
_inheritBaseTransitionEnumOption = new EnumOption
{
enabled = inheritBaseTransitionTypes.Length > 0,
selectedIndex = 0,
types = inheritBaseTransitionTypes,
typeFullNames = inheritBaseTransitionTypes
.Select(t => t.FullName)
.ToArray()
};
Type[] inheritBaseTransitionWithContextTypes = TypesHelpers.GetInheritGenericTypes(
allAbstractClassTypes,
baseTransitionWithContextType
);
_inheritBaseTransitionWithContextEnumOption = new EnumOption
{
enabled = inheritBaseTransitionWithContextTypes.Length > 0,
selectedIndex = 0,
types = inheritBaseTransitionWithContextTypes,
typeFullNames = inheritBaseTransitionWithContextTypes
.Select(t => t.FullName)
.ToArray()
};
_needVisualizeTransitions = true;
}
void OnGUI()
{
if (_inheritBaseTransitionEnumOption.types.Length < 1)
{
GUILayout.Label("BaseState implementation not exists. Create class with inherit BaseState type.");
return;
}
GUILayout.BeginVertical();
GUILayout.Space(5);
DrawSelectionType("BaseState type", _inheritBaseStateEnumOption);
DrawSelectionType("BaseTransition type", _inheritBaseTransitionEnumOption);
DrawSelectionType("BaseTransitionWithContext type", _inheritBaseTransitionWithContextEnumOption);
GUILayout.Space(5);
DrawSelectSourceCodeDirectoryPath();
GUILayout.Space(5);
_needVisualizeTransitions = EditorGUILayout.Toggle("Need visualize transitions", _needVisualizeTransitions);
GUILayout.Space(5);
if (GUILayout.Button("Generate"))
{
GenerateGraphvizCode();
}
GUILayout.Space(5);
DrawGenerationResult();
GUILayout.Space(5);
DrawVisualEditorsToShowing();
GUILayout.EndVertical();
}
void DrawSelectionType(string labelText, EnumOption enumOption)
{
GUILayout.BeginHorizontal();
enumOption.enabled = EditorGUILayout.Toggle("", enumOption.enabled, GUILayout.Width(15));
EditorGUI.BeginDisabledGroup(!enumOption.enabled);
GUILayout.Label(labelText, GUILayout.Width(400));
if (enumOption.enabled)
{
enumOption.selectedIndex = EditorGUILayout.Popup(enumOption.selectedIndex, enumOption.typeFullNames);
}
EditorGUI.EndDisabledGroup();
GUILayout.EndHorizontal();
}
void DrawSelectSourceCodeDirectoryPath()
{
GUILayout.BeginHorizontal();
if (string.IsNullOrEmpty(_sourceCodeDirectoryPath))
{
GUILayout.Label("⚠️");
}
GUILayout.Label("Select source code directory");
if (GUILayout.Button("Select", GUILayout.Width(100)))
{
string panelTitle = "Select source code folder";
_sourceCodeDirectoryPath = EditorUtility.OpenFolderPanel(panelTitle, Application.dataPath, "");
}
EditorGUI.BeginDisabledGroup(true);
GUILayout.TextField(_sourceCodeDirectoryPath, GUILayout.MinWidth(500), GUILayout.ExpandWidth(true));
EditorGUI.EndDisabledGroup();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
void DrawGenerationResult()
{
_generationResultScrollPosition = GUILayout.BeginScrollView(_generationResultScrollPosition);
GUILayout.BeginHorizontal();
GUILayoutOption[] layoutOptions = { GUILayout.MaxWidth(maxSize.x / 2) };
_sateGraphText = GUILayout.TextArea(_sateGraphText, layoutOptions);
if (_needVisualizeTransitions)
{
_sateGraphWithTransitionsText = GUILayout.TextArea(_sateGraphWithTransitionsText, layoutOptions);
}
GUILayout.EndHorizontal();
GUILayout.EndScrollView();
}
void DrawVisualEditorsToShowing()
{
GUILayout.BeginVertical();
GUILayout.Label("Visual editors to showing graphviz code:");
foreach (string graphvizVisualEditorUrl in GraphvizVisualEditorUrls)
{
GUILayout.BeginHorizontal();
GUILayout.Label(graphvizVisualEditorUrl, GUILayout.Width(250));
if (GUILayout.Button("Open", GUILayout.Width(50)))
{
Application.OpenURL(graphvizVisualEditorUrl);
}
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
GUILayout.EndVertical();
}
void GenerateGraphvizCode()
{
static Type GetSelectedType(EnumOption enumOption) => enumOption.types[enumOption.selectedIndex];
if (string.IsNullOrEmpty(_sourceCodeDirectoryPath))
{
Debug.LogWarning("Source code directory path is empty.");
return;
}
Type selectedBaseStateType = GetSelectedType(_inheritBaseStateEnumOption);
Type[] inheritSelectedBaseStateTypes = TypesHelpers.GetInheritTypes(_assemblyTypes, selectedBaseStateType);
var inheritSelectedTransitionTypes = new List<Type>();
if (_inheritBaseTransitionEnumOption.enabled)
{
Type selectedBaseTransitionType = GetSelectedType(_inheritBaseTransitionEnumOption);
Type[] inheritTypes = TypesHelpers.GetInheritTypes(_assemblyTypes, selectedBaseTransitionType);
inheritSelectedTransitionTypes.AddRange(inheritTypes);
}
if (_inheritBaseTransitionWithContextEnumOption.enabled)
{
Type selectedBaseTransitionWithContextType =
GetSelectedType(_inheritBaseTransitionWithContextEnumOption);
Type[] inheritTypes = TypesHelpers.GetInheritGenericTypes(
_assemblyTypes,
selectedBaseTransitionWithContextType
);
inheritSelectedTransitionTypes.AddRange(inheritTypes);
}
CodeAnalyzer.Result codeAnalyzeResult = CodeAnalyzer.Analyze(
inheritSelectedBaseStateTypes,
inheritSelectedTransitionTypes.ToArray(),
_sourceCodeDirectoryPath
);
HashSet<string> allSourceIds = new Dictionary<string, HashSet<string>>()
.Concat(codeAnalyzeResult.fromStateToTransitionByTransition)
.Concat(codeAnalyzeResult.fromTransitionToStateByState)
.Concat(codeAnalyzeResult.fromOtherSourceToTransitionByTransition)
.Concat(codeAnalyzeResult.fromOtherSourceToStateByState)
.SelectMany(kv => new HashSet<string>(kv.Value.Union(new[] { kv.Key })))
.ToHashSet();
string stateGraphName = StringsHelpers
.GetCommonSubstring(allSourceIds)
.TrimEnd('.');
string stateGraphDescription = $"FeatureName: {stateGraphName}";
_sateGraphText = Graphviz.StateGraphGenerator.GenerateStateGraph(codeAnalyzeResult, stateGraphDescription);
_sateGraphWithTransitionsText =
Graphviz.StateGraphWithTransitionsGenerator.Generate(codeAnalyzeResult, stateGraphDescription);
}
}
}
Копируем сгенерированный код семантики Graphviz и вставляем в какой-нибудь онлайн редактор. Пример:
Результат


Done.
Потенциально интересные заметки:
Помним, что чем меньше лишних файлов участвует в анализе кода, тем быстрее будет происходить парсинг.
Решение можно подключить к CI/CD системе, чтобы она автоматически прикрепляла стейт граф к pull request, если стейт граф имеется в дифе.
Другой пример фичи (кооперативной)
Тык


Изначально функционал делался на
Unity.GraphViewкак тулза, которая позволит генерить стейт граф для новых фич и редактировать существующие. НоUnity.GraphViewдля задач, которые не являются линейным конвейером (например, как shader), подходит не очень хорошо. Писать свои велосипеды пока что смысла не было.
Тык
За качество изображения уж простите :)

Полезные ссылки:
Почитать про основы Roslyn: Введение в Roslyn. Использование для разработки инструментов статического анализа
Статья на тему кодогенерации в Unity: Использование Roslyn для редактирования игрового контента
Руководство по Roslyn от Microsoft: Get started with syntax analysis (Roslyn APIs) - C# | Microsoft Learn
