Май 2016

Том 31 номер 5

.NET Compiler Platform - Добейтесь максимума возможного в своей работе с Model-View-ViewModel, используя Roslyn

Алессандро Дель Дель | Май 2016

Продукты и технологии:

Roslyn, C#, Visual Basic

В статье рассматриваются:

  • создание проекта преобразований кода (code refactorings) Roslyn;
  • разбор общих MVVM-классов с помощью Roslyn;
  • преобразование кода под конкретные платформы;
  • генерация собственной ViewModel с использованием Roslyn;
  • пример преобразования кода от Microsoft.

Исходный код можно скачать по ссылке

Model-View-ViewModel (MVVM) — очень популярный архитектурный шаблон, который отлично работает с такими платформами XAML-приложений, как Windows Presentation Foundation (WPF) и Universal Windows Platform (UWP). Проектирование архитектуры приложения на основе MVVM обеспечивает помимо прочего преимущества четкого разделения данных, прикладной логики и UI. Это облегчает сопровождение и тестирование приложений, повышает степень повторного использования кода и позволяет дизайнерам работать с UI без взаимодействия с логикой и данными. За прошедшие годы был создан ряд библиотек, шаблонов проектов и инфраструктур, например Prism и MVVM Light Toolkit, помогающих разработчикам эффективнее и проще реализовать MVVM. Однако в некоторых ситуациях вы не можете полагаться на внешние библиотеки или просто хотите иметь возможность быстрой реализации этого шаблона, уделяя основное внимание коду. Хотя существует множество реализаций MVVM, большинство из них совместно используют ряд общих объектов, генерацию которых можно легко автоматизировать с помощью Roslyn API.

В этой статье я объясню, как создавать собственные преобразования (refactorings) Roslyn, которые упрощают генерацию элементов, общих для всех реализаций MVVM. Поскольку полный обзор MVVM в этой статье просто невозможен, я исхожу из того, что вы уже имеете базовые знания шаблона MVVM, соответствующей терминологии и Roslyn API для анализа кода. Если вам нужно освежить свою память, вы можете прочитать следующие статьи: «Patterns — WPF Apps with the Model-View-ViewModel Design Pattern», «C# and Visual Basic: Use Roslyn to Write a Live Code Analyzer for Your API» и «C# — Adding a Code Fix to Your Roslyn Analyzer».

Сопутствующий этой статье исходный код доступен в двух версиях: для C# и Visual Basic. В статье также приводятся примеры кода как на C#, так и на Visual Basic.

Общие MVVM-классы

Любая типичная реализация MVVM требует, как минимум, следующих классов (в некоторых случаях с несколько иными именами — все зависит от используемой вами разновидности MVVM):

ViewModelBase Базовый абстрактный класс, предоставляющий члены, общие для каждого ViewModel в приложении. Общие члены могут варьироваться в зависимости от архитектуры приложения, но их самая базовая реализация доставляет уведомления об изменениях любому производному ViewModel.

RelayCommand Класс, представляющий команду, через которую объекты ViewModel могут вызывать методы. Обычно существует две разновидности RelayCommand: одна обобщенная, а другая необобщенная. В этой статье я буду использовать обобщенную версию (RelayCommand<T>).

Я предполагаю, что вы уже знакомы с обеими версиями, поэтому не стану вдаваться в детали. На рис. 1a представлен C#-код для ViewModelBase, а на рис. 1b — тот же код, но на Visual Basic.

Рис. 1a. Класс ViewModelBase (C#)

abstract class ViewModelBase : System.ComponentModel.
  INotifyPropertyChanged
{
  public event System.ComponentModel.
    PropertyChangedEventHandler PropertyChanged;

  // Генерируем уведомление об изменении свойства
  protected virtual void OnPropertyChanged(string propertyName)
  {
    PropertyChanged?.Invoke(this, new System.ComponentModel.
      PropertyChangedEventArgs(propertyName));
  }
}

Рис. 1b. Класс ViewModelBase (Visual Basic)

Public MustInherit Class ViewModelBase
  Implements System.ComponentModel.INotifyPropertyChanged

  Public Event PropertyChanged(sender As Object,
    e As System.ComponentModel.PropertyChangedEventArgs) _
    Implements System.ComponentModel.INotifyPropertyChanged.
    PropertyChanged

  Protected Sub OnPropertyChanged(propertyName As String)
    RaiseEvent PropertyChanged(Me, New System.ComponentModel.
    PropertyChangedEventArgs(propertyName))
  End Sub
End Class

Это самая базовая реализация ViewModelBase; она просто предоставляет уведомление об изменении свойства на основе интерфейса INotifyPropertyChanged. Конечно, у вас могут быть дополнительные члены в зависимости от ваших конкретных потребностей. На рис. 2a показан C#-код для RelayCommand<T>, а на рис. 2b — тот же код на Visual Basic.

Рис. 2a. Класс RelayCommand<T> (C#)

class RelayCommand<T> : System.Windows.Input.ICommand
{
  readonly Action<T> _execute = null;
  readonly Predicate<T> _canExecute = null;

  public RelayCommand(Action<T> execute)
    : this(execute, null)
  {
  }

  public RelayCommand(Action<T> execute,
    Predicate<T> canExecute)
  {
    if (execute == null)
      throw new ArgumentNullException(nameof(execute));

    _execute = execute;
    _canExecute = canExecute;
  }

  [System.Diagnostics.DebuggerStepThrough]
  public bool CanExecute(object parameter)
  {
    return _canExecute == null ? true :
      _canExecute((T)parameter);
  }

  public event EventHandler CanExecuteChanged;

  public void RaiseCanExecuteChanged()
  {
    var handler = CanExecuteChanged;
    if (handler != null)
    {
      handler(this, EventArgs.Empty);
    }
  }

  public void Execute(object parameter)
  {
    _execute((T)parameter);
  }
}

Рис. 2b. Класс RelayCommand(Of T) (Visual Basic)

Class RelayCommand(Of T)
  Implements System.Windows.Input.ICommand
  Private ReadOnly _execute As Action(Of T)
  Private ReadOnly _canExecute As Predicate(Of T)

  Public Sub New(execute As Action(Of T))
    Me.New(execute, Nothing)
  End Sub

  Public Sub New(execute As Action(Of T), _
    canExecute As Predicate(Of T))
    If execute Is Nothing Then
      Throw New ArgumentNullException(NameOf(execute))
    End If
    _execute = execute
    _canExecute = canExecute
  End Sub

  <System.Diagnostics.DebuggerStepThrough>
  Public Function CanExecute(parameter As Object) As Boolean _
    Implements System.Windows.Input.ICommand.CanExecute
    Return If(_canExecute Is Nothing, True, _
      _canExecute(parameter))
  End Function

  Public Event CanExecuteChanged As EventHandler Implements _
    System.Windows.Input.ICommand.CanExecuteChanged

  Public Sub RaiseCanExecuteChanged()
    RaiseEvent CanExecuteChanged(Me, EventArgs.Empty)
  End Sub

  Public Sub Execute(parameter As Object) _
    Implements ICommand.Execute
    _execute(parameter)
  End Sub
End Class

Это самая базовая реализация RelayCommand<T> и пригодная для большинства сценариев применения MVVM. Заметьте, что этот класс реализует интерфейс System.Windows.Input.ICommand, требующего реализации метода CanExecute, задача которого — сообщать вызвавшему коду, доступна ли команда для выполнения.

Как Roslyn может облегчить вашу жизнь

Если вы не работаете с внешними инфраструктурами, Roslyn может стать спасительным средством. Вы можете создавать пользовательские преобразования кода (custom code refactorings), которые заменяют определение какого-либо класса и автоматически реализуют требуемый объект. Кроме того, вы можете легко автоматизировать генерацию ViewModel-классов на основе свойств модели. На рис. 3 показан пример того, чего вы достигнете к концу статьи.

Реализация MVVM-объектов с помощью пользовательского преобразования Roslyn
Рис. 3. Реализация MVVM-объектов с помощью пользовательского преобразования Roslyn

Преимущество этого подхода в том, что вы всегда концентрируетесь на редакторе кода и можете очень быстро реализовать необходимые объекты. Более того, вы можете генерировать собственный ViewModel на основе класса модели, как будет продемонстрировано далее в этой статье. Давайте приступим к созданию проекта пользовательских преобразований.

Создание проекта для пользовательских преобразований Roslyn

Первый шаг заключается в создании нового преобразования Roslyn. Для этого можно задействовать шаблон проекта Code Refactoring (VSIX), доступный в узле Extensibility под выбранным вами языком в диалоге New Project. Назовем новый проект MVVM_Refactoring, как показано на рис. 4.

Создание проекта вариации Roslyn
Рис. 4. Создание проекта вариации Roslyn

Если готовы, щелкните OK. При генерации проекта Visual Studio 2015 автоматически добавляет класс MVVMRefactoringCodeRefactoringProvider, определенный в файле CodeRefactoringProvider.cs (или .vb для Visual Basic). Переименуйте эти класс и файл в MakeViewModelBaseRefactoring и MakeViewModelBaseRefactoring.cs соответственно. Для ясности удалите оба автоматически сгенерированных метода: ComputeRefactoringsAsync и ReverseTypeNameAsync (последний автоматически генерируется для демонстрационных целей).

Исследование узла синтаксиса

Как вы, вероятно, знаете, главная точка входа для преобразования кода (code refactoring) — метод ComputeRefactoringsAsync, отвечающий за создание так называемого быстрого действия (quick action), которое будет подключено к значку лампочки в редакторе кода, если анализ кода синтаксического узла удовлетворяет требуемым правилам. В данном конкретном случае метод ComputeefactoringsAsync должен обнаруживать, активирует ли разработчик значок лампочки для объявления класса. С помощью окна Syntax Visualizer вы можете легко понять синтаксические элементы, с которыми вам понадобится работать. Точнее, в C# нужно обнаруживать, является ли синтаксический узел объявлением ClassDeclaration, представленным объектом типа Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax (рис. 5), тогда как в Visual Basic вы определяете, является ли синтаксический узел ClassStatement, представленным объектом типа Microsoft.CodeAnalysis.VisualBasic.Syntax.ClassStatementSyntax. На самом деле ClassStatement в Visual Basic — это дочерний узел ClassBlock, представляющий весь код для класса. Причина, по которой C# и Visual Basic имеют разные объекты, заключается в том, как каждый из них представляет определение класса: C# использует ключевое слово class с фигурными скобками в качестве отделителей, а Visual Basic — ключевое слово Class с оператором End Class в качестве отделителя.

Понимание объявления класса
Рис. 5. Понимание объявления класса

Создание действия

Первое из рассматриваемых преобразований кода относится к классу ViewModelBase. Сначала нужно написать метод ComputeRefactoringsAsync в классе MakeViewModelBaseRefactoring. С помощью этого метода вы проверяете, представляет ли данный синтаксический узел объявление класса; если да, можно создать и зарегистрировать действие, которое будет доступно при щелчке значка лампочки. На рис. 6a показано, как это делается в коде на C#, а на рис. 6b — в коде на Visual Basic (см. комментарии в коде).

Рис. 6a. Главная точка входа: метод ComputeRefactoringsAsync (C#)

private string Title = "Make ViewModelBase class";

public async sealed override Task ComputeRefactoringsAsync(
  CodeRefactoringContext context)
{
  // Получаем корневой узел дерева синтаксиса
  var root = await context.Document.
    GetSyntaxRootAsync(context.CancellationToken).
    ConfigureAwait(false);

  // Находим узел в выбранном блоке
  var node = root.FindNode(context.Span);
  
  // Это узел выражения class?
  var classDecl = node as ClassDeclarationSyntax;
  if (classDecl == null)
  {
    return;
  }

  // Если да, создаем действие, предлагающее преобразование
  var action = CodeAction.Create(title: Title,
    createChangedDocument: c =>
    MakeViewModelBaseAsync(context.Document,
      classDecl, c), equivalenceKey: Title);

  // Регистрируем это действие над кодом
  context.RegisterRefactoring(action);
}

Рис. 6b. Главная точка входа: метод ComputeRefactoringsAsync (Visual Basic)

Private Title As String = "Make ViewModelBase class"
Public NotOverridable Overrides Async Function _
  ComputeRefactoringsAsync(context As CodeRefactoringContext) _
  As Task
  ' Получаем корневой узел дерева синтаксиса
  Dim root = Await context.Document.
    GetSyntaxRootAsync(context.CancellationToken).
    ConfigureAwait(False)

  ' Находим узел в выбранном блоке
  Dim node = root.FindNode(context.Span)

  ' Это узел выражения class?
  Dim classDecl = TryCast(node, ClassStatementSyntax)
  If classDecl Is Nothing Then Return

  ' Если да, создаем действие, предлагающее преобразование
  Dim action = CodeAction.Create(title:=Title, _
    createChangedDocument:=Function(c) _
    MakeViewModelBaseAsync(context.
    Document, classDecl, c), equivalenceKey:=Title)

  ' Регистрируем это действие над кодом
  context.RegisterRefactoring(action)
End Function

С помощью этого кода вы зарегистрировали действие, которое будет вызвано применительно к синтаксическому узлу, если он относится к объявлению класса. Действие выполняется методом MakeViewModelBaseAsync, который реализует логику рефакторинга (преобразования) и предоставляет совершенно новый класс.

Генерация кода

Roslyn не только предоставляет объектно-ориентированный, структурированный способ представления исходного кода, но и позволяет разбирать исходный текст, а также генерировать синтаксическое дерево с полной точностью. Чтобы сгенерировать новое синтаксическое дерево из чистого текста, вызовите метод SyntaxFactory.ParseSyntaxTree. Он принимает аргумент типа System.String, содержащий исходный код, из которого вы хотите сгенерировать SyntaxTree.

Roslyn также предлагает методы VisualBasicSyntaxTree.ParseText и CSharpSyntaxTree.ParseText для достижения того же результата; однако в данном случае имеет смысл использовать SyntaxFactory.ParseSyntaxTree, так как код вызывает другие методы Parse из SyntaxFactory, как вы вскоре увидите.

Получив новый экземпляр SyntaxTree, вы можете выполнять анализ кода и другие операции над ним. Например, можно разобрать исходный код как целый класс, сгенерировать из него синтаксическое дерево, заменить синтаксический узел в классе и вернуть новый класс. В случае шаблона MVVM общие классы имеют фиксированную структуру, поэтому процесс разбора исходного текста и замена определения класса на новое осуществляется легко и быстро. Используя преимущества так называемых многострочных строковых литералов (multi-line string literals), можно вставить определение всего класса в объект типа System.String, затем получить из него SyntaxTree, извлечь SyntaxNode, соответствующий определению класса, и заменить исходный класс в дереве на новый. Сначала я продемонстрирую, как это сделать применительно к классу ViewModelBase. На рис. 7a показан код на C#, а на рис. 7b — код на Visual Basic.

Рис. 7a. MakeViewModelBaseAsync: генерация нового синтаксического дерева из исходного текста (C#)

private async Task<Document> MakeViewModelBaseAsync(
  Document document, ClassDeclarationSyntax classDeclaration,
  CancellationToken cancellationToken)
{

  // Определение класса, представленное в виде исходного текста
  string newImplementation = @"abstract class ViewModelBase :
    INotifyPropertyChanged
{
public event System.ComponentModel.PropertyChangedEventHandler
  PropertyChanged;

// Генерируем уведомление об изменении свойства
protected virtual void OnPropertyChanged(string propertyName)
{
  PropertyChanged?.Invoke(this, new System.ComponentModel.
    PropertyChangedEventArgs(propertyName));
}
}
";
  // 1. ParseSyntaxTree() получает новый SyntaxTree
  //    из исходного текста
  // 2. GetRoot() получает корневой узел дерева
  // 3. OfType<ClassDeclarationSyntax>().FirstOrDefault()
  //    извлекает из дерева лишь определение класса
  // 4. WithAdditionalAnnotations() вызывается
  //    для форматирования кода
  var newClassNode = SyntaxFactory.ParseSyntaxTree(
    newImplementation).GetRoot().DescendantNodes().
    OfType<ClassDeclarationSyntax>().FirstOrDefault().
    WithAdditionalAnnotations(Formatter.Annotation,
    Simplifier.Annotation);

  // Получаем корневой SyntaxNode документа
  var root = await document.GetSyntaxRootAsync();

  // Генерируем новый CompilationUnitSyntax (представляет
  // файл кода), заменяющий старый класс новым
  CompilationUnitSyntax newRoot = (CompilationUnitSyntax)root.
    ReplaceNode(classDeclaration,
    newClassNode).NormalizeWhitespace();

  // Определяем, есть ли директива using System.ComponentModel
  if ((newRoot.Usings.Any(u => u.Name.ToFullString() ==
    "System.ComponentModel"))== false)
  {
    // Если нет, добавляем
    newRoot = newRoot.AddUsings(SyntaxFactory.UsingDirective(
      SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName(
      "System"), SyntaxFactory.IdentifierName(
      "ComponentModel"))));
  }

  // Генерируем новый документ на основе нового SyntaxNode
  var newDocument = document.WithSyntaxRoot(newRoot);

  // Возвращаем новый документ
  return newDocument;
}

Рис. 7b. MakeViewModelBaseAsync: генерация нового синтаксического дерева из исходного текста (Visual Basic)

Private Async Function MakeViewModelBaseAsync( _
  document As Document, classDeclaration As _
  ClassStatementSyntax, cancellationToken As _
  CancellationToken) As Task(Of Document)

  ' Определение класса, представленное в виде исходного текста
  Dim newImplementation = "Public MustInherit Class _
    ViewModelBase Implements INotifyPropertyChanged

  Public Event PropertyChanged(sender As Object,
    e As PropertyChangedEventArgs) _
    Implements INotifyPropertyChanged.PropertyChanged

  Protected Sub OnPropertyChanged(propertyName As String)
    RaiseEvent PropertyChanged(Me,
      New PropertyChangedEventArgs(propertyName))
  End Sub
End Class
"
  ' 1. ParseSyntaxTree() получает новый SyntaxTree
  '    из исходного текста
  ' 2. GetRoot() получает корневой узел дерева
  ' 3. OfType(Of ClassDeclarationSyntax)().FirstOrDefault()
  '    извлекает из дерева лишь определение класса
  ' 4. WithAdditionalAnnotations() вызывается
  '    для форматирования кода
  Dim newClassNode = SyntaxFactory.ParseSyntaxTree(
    newImplementation).GetRoot().DescendantNodes().
    OfType(Of ClassBlockSyntax)().FirstOrDefault().
    WithAdditionalAnnotations(Formatter.Annotation,
    Simplifier.Annotation)

  Dim parentBlock = CType(classDeclaration.Parent,
    ClassBlockSyntax)

  ' Получаем корневой SyntaxNode документа
  Dim root = Await document.GetSyntaxRootAsync(
    cancellationToken)

  ' Генерируем новый CompilationUnitSyntax (представляет
  ' файл кода), заменяющий старый класс новым
  Dim newRoot As CompilationUnitSyntax = root.ReplaceNode(
    parentBlock, newClassNode).NormalizeWhitespace()

  ' Определяем, есть ли директива Imports System.ComponentModel
  If Not newRoot.Imports.Any(Function(i) i.ImportsClauses.
    Where(Function(f) f.ToString =
    "System.ComponentModel").Any) Then
    ' Если нет, добавляем
    Dim newImp = SyntaxFactory.ImportsStatement(SyntaxFactory.
      SingletonSeparatedList(Of ImportsClauseSyntax)
      (SyntaxFactory.SimpleImportsClause(SyntaxFactory.
      ParseName("System.ComponentModel"))))
    newRoot = newRoot.AddImports(newImp)
  End If

  ' Генерируем новый документ на основе нового SyntaxNode
  Dim newDocument = document.WithSyntaxRoot(newRoot)

  ' Возвращаем новый документ
  Return newDocument
End Function

Поскольку тип SyntaxFactory используется многократно, вы могли бы выполнять статический импорт и тем самым упростить свой код, добавив директиву using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory в C#. У меня в коде статического импорта нет, чтобы вам было легче найти некоторые из предлагаемых SyntaxFactory методов.

Заметьте, что метод MakeViewModelBaseAsync принимает три аргумента:

  • Document — представляет текущий файл исходного кода;
  • ClassDeclarationSyntax (в Visual Basic это ClassStatementSyntax) — представляет объявление класса, через который осуществляется анализ кода;
  • CancellationToken — используется, если операция должна быть отменена.

Сначала код вызывает SyntaxFactory.ParseSyntaxTree, чтобы получить новый экземпляр SyntaxTree на основе исходного текста, который представляет класс ViewModelBase. Вызов GetRoot необходим, чтобы получить экземпляр корневого SyntaxNode для синтаксического дерева. В данном конкретном сценарии вам заранее известно, что разобранный исходный текст содержит определение только одного класса, поэтому код вызывает FirstOrDefault<T> применительно к OfType<T>, извлекая один узел-потомок требуемого типа, каковым в C# является ClassDeclarationSyntax, а в Visual Basic — ClassBlockSyntax. К этому моменту вам нужно заменить определение исходного класса классом ViewModelBase. Для этого код сначала вызывает Document.GetSyntaxRootAsync, чтобы асинхронно извлечь корневой узел синтаксического дерева документа, а затем вызывает ReplaceNode для замены определения старого класса новым классом ViewModelBase. Обратите внимание на то, как код определяет, существует ли директива using (C#) или Imports (Visual Basic) для пространства имен System.ComponentModel, анализируя наборы CompilationUnitSyntax.Usings и CompilationUnitSyntax.Imports соответственно. Если директивы нет, добавляется соответствующая директива.

Помните, что в Roslyn объекты являются неизменяемыми. Это та же концепция, которая применяется к классу String: на самом деле модифицировать строку нельзя, поэтому, когда вы редактируете какую-то строку или вызываете методы вроде Replace, Trim или Substring, вы получаете новую строку с внесенными изменениями. По этой причине всякий раз, когда вы редактируете синтаксический узел, создается новый синтаксический узел с обновленными свойствами.

В Visual Basic коду также нужно получить родительский ClassBlockSyntax для текущего синтаксического узла, заменяющий тип ClassStatementSyntax. Это требуется для получения экземпляра настоящего SyntaxNode, который будет заменяться. Общая реализация класса RelayCommand<T> работает точно так же, но вы должны добавить новое преобразование кода. Для этого щелкните правой кнопкой мыши название проекта в Solution Explorer и выберите Add | New Item. В диалоге Add New Item укажите Refactoring template и имя нового файла MakeRelayCommandRefactoring.cs (или .vb в случае Visual Basic). Логика преобразования (рефакторинга) та же, что и для класса ViewModelBase (конечно, исходный текст будет другим). На рис. 8a показан полный код на C# для нового преобразования, включающего методы ComputeRefactoringsAsync и MakeRelayCommandAsync, а на рис. 8b — аналогичный код для Visual Basic.

Рис. 8a. Преобразование кода, реализующее класс RelayCommand<T> (C#)

[ExportCodeRefactoringProvider(LanguageNames.CSharp,
  Name = nameof(MakeRelayCommandRefactoring)), Shared]
internal class MakeRelayCommandRefactoring :
  CodeRefactoringProvider
{
  private string Title = "Make RelayCommand<T> class";
  public async sealed override Task
    ComputeRefactoringsAsync(CodeRefactoringContext context)
  {
    var root = await context.Document.GetSyntaxRootAsync(
      context.CancellationToken).ConfigureAwait(false);

    // Находим узел в выделенном блоке
    var node = root.FindNode(context.Span);

    // Только предлагаем преобразование,
    // если выделен узел выражения class
    var classDecl = node as ClassDeclarationSyntax;
    if (classDecl == null)
    {
      return;
    }
    var action = CodeAction.Create(title: Title,
      createChangedDocument: c =>
      MakeRelayCommandAsync(context.Document,
      classDecl, c), equivalenceKey: Title);

    // Регистрируем это действие над кодом
    context.RegisterRefactoring(action);
  }

  private async Task<Document>
    MakeRelayCommandAsync(Document document,
    ClassDeclarationSyntax classDeclaration,
    CancellationToken cancellationToken)
  {

    // Определение класса, представленное как исходный текст
    string newImplementation = @"
class RelayCommand<T> : ICommand
{
  readonly Action<T> _execute = null;
  readonly Predicate<T> _canExecute = null;

  public RelayCommand(Action<T> execute)
    : this(execute, null)
  {
  }

  public RelayCommand(Action<T> execute,
    Predicate<T> canExecute)
  {
    if (execute == null)
      throw new ArgumentNullException(""execute"");

      _execute = execute;
        _canExecute = canExecute;
  }

  [System.Diagnostics.DebuggerStepThrough]
  public bool CanExecute(object parameter)
  {
    return _canExecute == null ? true :
      _canExecute((T)parameter);
  }

  public event EventHandler CanExecuteChanged;

  public void RaiseCanExecuteChanged()
  {
    var handler = CanExecuteChanged;
    if (handler != null)
    {
      handler(this, EventArgs.Empty);
    }
  }

  public void Execute(object parameter)
  {
    _execute((T)parameter);
  }
}
";
    // 1. ParseSyntaxTree() получает новый SyntaxTree
    //    из исходного текста
    // 2. GetRoot() получает корневой узел дерева
    // 3. OfType<ClassDeclarationSyntax>().FirstOrDefault()
    //    извлекает из дерева только определение класса
    // 4. WithAdditionalAnnotations() вызывается
    //    для форматирования кода
    var newClassNode = SyntaxFactory.ParseSyntaxTree(
      newImplementation).GetRoot().DescendantNodes().
      OfType<ClassDeclarationSyntax>().FirstOrDefault().
      WithAdditionalAnnotations(Formatter.Annotation,
      Simplifier.Annotation);

    // Получаем корневой SyntaxNode документа
    var root = await document.GetSyntaxRootAsync(
      cancellationToken);

    // Генерируем новый CompilationUnitSyntax (представляет
    // файл кода), заменяющий старый класс новым
    CompilationUnitSyntax newRoot = (CompilationUnitSyntax)
      root.ReplaceNode(classDeclaration,
      newClassNode).NormalizeWhitespace();

    if ((newRoot.Usings.Any(u => u.Name.ToFullString() ==
      "System.Windows.Input")) == false)
    {
      newRoot = newRoot.AddUsings(SyntaxFactory.UsingDirective(
        SyntaxFactory QualifiedName(SyntaxFactory.
        IdentifierName("System"),
        SyntaxFactory.IdentifierName("Windows.Input"))));
    }

    // Генерируем новый документ на основе нового SyntaxNode
    var newDocument = document.WithSyntaxRoot(newRoot);

    // Возвращаем новый документ
    return newDocument;
  }
}

Рис. 8b. Преобразование кода, реализующее класс RelayCommand(Of T) (Visual Basic)

<ExportCodeRefactoringProvider(LanguageNames.VisualBasic,
  Name:=NameOf(MakeRelayCommandRefactoring)), [Shared]>
Friend Class MakeRelayCommandRefactoring
  Inherits CodeRefactoringProvider

  Public NotOverridable Overrides Async Function _
    ComputeRefactoringsAsync(context As _
    CodeRefactoringContext) As Task

    Dim root = Await context.Document.
      GetSyntaxRootAsync(context.CancellationToken).
      ConfigureAwait(False)

      ' Находим узел в выделенном блоке
      Dim node = root.FindNode(context.Span)

      ' Только предлагаем преобразование,
    ' если выделен узел выражения class
    Dim classDecl = TryCast(node, ClassStatementSyntax)
    If classDecl Is Nothing Then Return

    Dim action = CodeAction.Create("Make RelayCommand(Of T) _
      class", Function(c) MakeRelayCommandAsync( _
      context.Document, classDecl, c))

    ' Регистрируем это действие над кодом
    context.RegisterRefactoring(action)
  End Function

  Private Async Function MakeRelayCommandAsync(document _
    As Document, classDeclaration As ClassStatementSyntax, _
    cancellationToken As CancellationToken) _
    As Task(Of Document)

    ' Определение класса, представленное как исходный текст
    Dim newImplementation = "Class RelayCommand(Of T)
  Implements ICommand

  Private ReadOnly _execute As Action(Of T)
  Private ReadOnly _canExecute As Predicate(Of T)

  Public Sub New(ByVal execute As Action(Of T))
    Me.New(execute, Nothing)
  End Sub

  Public Sub New(ByVal execute As Action(Of T),
    ByVal canExecute As Predicate(Of T))

    If execute Is Nothing Then
      Throw New ArgumentNullException(""execute"")
    End If
    _execute = execute
    _canExecute = canExecute
  End Sub

  <DebuggerStepThrough> _
  Public Function CanExecute(ByVal parameter As Object) As _
    Boolean Implements ICommand.CanExecute
    Return If(_canExecute Is Nothing, True, _canExecute( _
      CType(parameter, T)))
  End Function

  Public Event CanExecuteChanged As EventHandler _
    Implements ICommand.CanExecuteChanged

  Public Sub RaiseCanExecuteChanged() 
    RaiseEvent CanExecuteChanged(Me, EventArgs.Empty)
  End Sub

  Public Sub Execute(ByVal parameter As Object) _
    Implements ICommand.Execute _
    _execute(CType(parameter, T))
  End Sub
End Class"

  ' 1. ParseSyntaxTree() получает новый SyntaxTree
  '    из исходного текста
  ' 2. GetRoot() получает корневой узел дерева
  ' 3. OfType(Of ClassDeclarationSyntax)().FirstOrDefault()
  '    извлекает из дерева лишь определение класса
  ' 4. WithAdditionalAnnotations() вызывается
  '    для форматирования кода
  Dim newClassNode = SyntaxFactory.ParseSyntaxTree( _
    newImplementation).GetRoot().DescendantNodes().
    OfType(Of ClassBlockSyntax)().FirstOrDefault().
    WithAdditionalAnnotations(Formatter.Annotation,
    Simplifier.Annotation)

  Dim parentBlock = CType(classDeclaration.Parent, _
    ClassBlockSyntax)

  Dim root = Await document.GetSyntaxRootAsync( _
    cancellationToken)

  ' Генерируем новый CompilationUnitSyntax (представляет
  ' файл кода), заменяющий старый класс новым
  Dim newRoot As CompilationUnitSyntax = _
    root.ReplaceNode(parentBlock, newClassNode)

  ' Определяем, есть ли директива Imports System.Windows.Input
  If Not newRoot.Imports.Any(Function(i) i.ToFullString.
    Contains("System.Windows.Input")) Then
      ' Если нет, добавляем
      Dim newImp = SyntaxFactory.ImportsStatement(
      SyntaxFactory.SingletonSeparatedList(
      Of ImportsClauseSyntax)
      (SyntaxFactory.SimpleImportsClause(SyntaxFactory.
      ParseName("System.Windows.Input"))))

      newRoot = newRoot.AddImports(newImp)
    End If

    Dim newDocument = document.WithSyntaxRoot(newRoot)

    Return newDocument
  End Function
End Class

Вы успешно завершили создание двух пользовательских преобразований кода и теперь располагаете основами для реализации дополнительных преобразований в зависимости от своей реализации шаблона MVVM (например, брокера сообщений, локатора сервисов и классов сервисов).

В качестве альтернативы вы могли бы использовать класс SyntaxGenerator. Он предлагает независимые от конкретного языка API, а значит, код преобразований, который вы пишете, можно применять как к Visual Basic, так и C#. Однако этот подход требует генерации каждого синтаксического элемента для исходного текста. Используя SyntaxFactory.ParseSyntaxTree, вы можете разбирать любой исходный текст. Это особенно полезно, если вы пишете средства разработки, манипулирующие исходным текстом так, как вам заранее не известно.

Доступность: UWP-приложения и WPF

Вместо универсальной доступности пользовательских преобразований кода имеет смысл ограничивать их доступность через значок лампочки только теми платформами, где вы реально используете MVVM, такими как WPF и UWP. В методе ComputeRefactoringsAsync можно получить экземпляр семантической модели текущего документа, а затем вызвать метод GetTypeByMetadataName из свойства Compilation. Например, следующий код демонстрирует, как сделать доступным преобразование только для UWP-приложений:

// Ограничиваем доступность преобразования
// только для приложений Windows 10
var semanticModel =
  await context.Document.GetSemanticModelAsync();
var properPlatform = semanticModel.Compilation.
  GetTypeByMetadataName("Windows.UI.Xaml.AdaptiveTrigger");

if (properPlatform != null)
{
  var root = await context.Document.
    GetSyntaxRootAsync(context.CancellationToken).
    ConfigureAwait(false);

  // ...
}

Поскольку тип Windows.UI.Xaml.AdaptiveTrigger существует только в UWP-приложениях, это преобразование будет доступно, если механизм анализа кода обнаружит наличие ссылок на этот тип. Если вы хотите сделать преобразование доступным для WPF, вы могли бы написать такую проверку:

// Ограничиваем доступность преобразования
// только для WPF-приложений
var semanticModel =
  await context.Document.GetSemanticModelAsync();
var properPlatform = semanticModel.Compilation.
  GetTypeByMetadataName(
  "System.Windows.Navigation.JournalEntry");

Аналогично System.Windows.Navigation.JournalEntry является уникальным для WPF типом, поэтому ненулевой результат от GetTypeByMetadataName будет означать, что механизм анализа кода имеет дело с WPF-проектом. Конечно, можно скомбинировать обе проверки, чтобы сделать преобразования доступными для обеих платформ.

Тестирование кода

Вы можете протестировать проделанную на данный момент работу в экспериментальном экземпляре Visual Studio, нажав F5. Например, создайте WPF-проект и добавьте следующий очень простой класс:

class EmptyClass
{
  string aProperty { get; set; }
}

Щелкните правой кнопкой мыши объявление класса и выберите Quick Actions из контекстного меню. В этот момент значок лампочки покажет два новых преобразования, как и ожидалось, и выдаст должное предложение (вернитесь к рис. 3).

Если вы хотите опубликовать пользовательские преобразования, шаблон проекта Code Refactoring (VSIX) автоматизирует генерацию VSIX-пакета, который можно публиковать в Visual Studio Gallery. Если же вы предпочитаете опубликовать свою работу в виде NuGet-пакета, вам придется создать Analyzer с проектом Code Fix, а затем добавить шаблоны элемента Code Fix.

Генерация пользовательского ViewModel с помощью Roslyn

Если вам интересно, почему применение Roslyn может быть более эффективным подходом, чем простое добавление статического текста в класс, вообразите, что вам нужно автоматизировать генерацию ViewModel из какого-либо прикладного класса, который является моделью. В этом случае вы можете сгенерировать новый класс ViewModel и добавить необходимые свойства на основе данных, предоставляемых моделью. Просто чтобы вы получили некоторое представление, на рис. 9 показано, как создать преобразование с названием «Make ViewModel class». Этот код демонстрирует, как создать упрощенную версию ViewModel.

Рис. 9. Генерация ViewModel на основе модели

private async Task<Document> MakeViewModelAsync(
  Document document, ClassDeclarationSyntax classDeclaration,
  CancellationToken cancellationToken)
{
  // Получаем имя класса модели
  var modelClassName = classDeclaration.Identifier.Text;
  // Имя класса ViewModel
  var viewModelClassName = $"{modelClassName}ViewModel";

  // Только в демонстрационных целях придание имени объекта
  // множественной формы осуществляется простым добавлением
  // буквы "s". Продумайте подходящие алгоритмы.
  string newImplementation = $@"class {viewModelClassName} :
    INotifyPropertyChanged
{{
public event PropertyChangedEventHandler PropertyChanged;

// Генерируем уведомление об изменении свойства
protected virtual void OnPropertyChanged(string propname)
{{
  PropertyChanged?.Invoke(this,
    new PropertyChangedEventArgs(propname));
}}

private ObservableCollection<{modelClassName}>
  _{modelClassName}s;

public ObservableCollection<{modelClassName}> {modelClassName}s
{{
  get {{ return _{modelClassName}s; }}
  set
  {{
    _{modelClassName}s = value;
    OnPropertyChanged(nameof({modelClassName}s));
  }}
}}

public {viewModelClassName}() {{
// Реализуйте свою логику для загрузки набора элементов
}}
}}
";

    var newClassNode = SyntaxFactory.ParseSyntaxTree(
      newImplementation).GetRoot().DescendantNodes().
      OfType<ClassDeclarationSyntax>().FirstOrDefault().
      WithAdditionalAnnotations(Formatter.Annotation,
      Simplifier.Annotation);

    // Получаем объявление родительского пространства имен
    var parentNamespace = (NamespaceDeclarationSyntax)
      classDeclaration.Parent;
    // Добавляем новый класс в это пространство имен
    var newParentNamespace = parentNamespace.AddMembers(
      newClassNode).NormalizeWhitespace();

    var root = await document.GetSyntaxRootAsync(
      cancellationToken);

    CompilationUnitSyntax newRoot =
      (CompilationUnitSyntax)root;

    newRoot = newRoot.ReplaceNode(parentNamespace,
      newParentNamespace).NormalizeWhitespace();

    newRoot = newRoot.AddUsings(SyntaxFactory.UsingDirective(
      SyntaxFactory.QualifiedName(SyntaxFactory.IdentifierName(
        "System"), SyntaxFactory.IdentifierName(
        "Collections.ObjectModel"))),
        SyntaxFactory.UsingDirective(SyntaxFactory.
      QualifiedName(SyntaxFactory.IdentifierName("System"),
        SyntaxFactory.IdentifierName("ComponentModel"))));

    // Генерируем новый документ на основе нового SyntaxNode
    var newDocument = document.WithSyntaxRoot(newRoot);

    // Возвращаем новый документ
    return newDocument;
  }

Этот код генерирует класс ViewModel, который предоставляет ObservableCollection типа модели плюс пустой конструктор, где вы должны реализовать свою логику, как показано на рис. 10. Конечно, этот код следует расширить любыми дополнительными членами, которые могут вам понадобиться, такими как свойства данных и команды, и он должен быть улучшен за счет более эффективного алгоритма придания множественной формы имени.

Автоматизация генерации класса ViewModel
Рис. 10. Автоматизация генерации класса ViewModel

Учимся у Microsoft: рефакторинг INotifyPropertyChanged

Одна из повторяющихся задач, выполняемых вами с MVVM, — реализация уведомления об изменениях для классов в вашей модели данных через интерфейс System.ComponentModel.INotifyPropertyChanged. Например, если у вас был следующий класс Customer:

class Customer
{
  string CompanyName { get; set; }
}

Вы должны реализовать INotifyPropertyChanged, чтобы связанные объекты уведомлялись о любых изменениях в данных:

class Customer : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;
  string _companyName;
  string CompanyName
  {
    get { return _companyName; }
    set {
      _companyName = value;
      PropertyChanged?.Invoke(this,
        new PropertyChangedEventArgs(nameof(CompanyName))); }
  }
}

Как уже понятно, в модели данных, состоящей из множества классов с десятками свойств, эта задача может отнимать массу времени. Помимо сопутствующих примеров для Roslyn, Microsoft поставляет преобразование кода, которое автоматизирует реализацию интерфейса INotifyPropertyChanged простым щелчком кнопки мыши. Оно называется ImplementNotifyPropertyChanged и доступно как для C#, так и для Visual Basic в подпапке Src/Samples репозитария Roslyn на github.com/dotnet/roslyn. Если вы скомпилируете и протестируете этот пример, то увидите, насколько быстро и эффективно он осуществляет реализацию интерфейса INotifyPropertyChanged (рис. 11).

Реализация интерфейса INotifyPropertyChanged
Рис. 11. Реализация интерфейса INotifyPropertyChanged

Этот пример особенно полезен, поскольку показывает, как использовать Roslyn API для прохода по определению объекта, как разбирать конкретные члены и как вносить правки в существующие свойства без предоставления полностью нового определения класса. Настоятельно рекомендую изучить исходный код этого примера, чтобы понимать более сложные сценарии генерации кода.

Заключение

Помимо почти бесконечного числа возможных применений, Roslyn также невероятно упрощает поддержку шаблона Model-View-ViewModel. Как я показал в этой статье, вы можете использовать Roslyn API для разбора исходного кода некоторых классов, обязательных в любой реализации MVVM, например ViewModelBase и RelayCommand<T>, и генерации нового синтаксического узла, который может заменить существующее определение класса. Ну а Visual Studio 2015 отобразит предварительное представление через значок лампочки, обеспечивая потрясающее удобство кодирования.


Алессандро Дель Соуле (Alessandro Del Sole) был Microsoft MVP с 2008 года. Награждался званием MVP of the Year пять раз. Автор многих книг, электронных книг, обучающих видеороликов и статей по .NET-разработке в Visual Studio. Следите за его заметками в Twitter (@progalex).

Выражаю благодарность за рецензирование статьи экспертам Джейсону Боку (JasonBock) изMagenic и Энтони Грину (AnthonyGreen) изMicrosoft.


Discuss this article in the MSDN Magazine forum