使用Roslyn的C#语言服务实现UML类图的自动生成

By | 2018年9月9日

最近在项目中实现了一套基于Windows Forms的开发框架,个人对于本身的设计还是比较满意的,因此,打算将这部分设计整理成文档,通过一些UML图形比如类图(Class Diagram)来描述整个框架的设计。然而,并没有找到一款合适的UML设计工具,商用版的功能强大,但即便是个人许可,一个License也不下千元;免费社区版的UML工具中,draw.io可以推荐一下,画出的图表看上去都非常专业,然而对于UML图的支持不算特别好,画起来也不太方便;有一款比较好的:Astah Community,虽然使用比较方便,但是面对着复杂的类之间的关系,要一个个手工去画,显得非常麻烦而且容易出错。总而言之,并没有找到一个合适的方法,能够快速准确地产生专业的UML类图,归根结底,还是“穷”+“懒”。

PlantUML

在网上搜索调研各个UML制图工具的时候,发现了PlantUML,个人觉得它的设计理念是非常好的:通过简单的文本来描述UML图,然后通过专业的渲染引擎将文本内容转化成图形。PlantUML的官方网站是:http://plantuml.com/,它是一个开源项目,工具集本身针对不同的许可协议有着不同的编译,因此,你可以根据自己的需要选择使用相对应的版本。PlantUML本身不仅可以支持UML类图的定义,而且可以支持包括时序图、用例图、活动图等9中UML图形,还可以支持包括架构图、甘特图等6种非UML图形。详细内容可以直接参考官网,都是中文版的,简单容易。不过,今天我们只演示UML类图的自动化产生。

举个例子,下面的PlantUML文本:

@startuml test

Dummy2 <|-- Dummy1
Dummy1 *... Dummy3
Dummy1 --- Dummy4
IDummy <|.. Dummy4

interface IDummy {
  + DoSomething(parameter: Object): boolean
}

class Dummy1 {
  + myMethods()
}

class Dummy2 {
  + hiddenMethod()
}

class Dummy3 {
   String name
}

class Dummy4 {
  - field1: string
  - field2: int
  # field3: DateTime
  + DoSomething(parameter: Object): boolean
}

@enduml

会生成下图所示的UML类图:

image

有关PlantUML的语言语法定义,这里就不多说明了,官方网站上有详细的文档,而且还有PDF格式的使用手册可供免费下载。不过,从上面的例子,我们大概可以得知:

  1. PlantUML需要由@startuml和@enduml两条语句来标注起始,@startuml后可以跟上类图的名称
  2. 可以通过不同的符号来标注类、接口之间的关系,事实上,PlantUML的语法定义还是非常随意的,这些定义可以放在文件的任何位置,不过有可能会影响所产生的UML图的布局
  3. 每个接口,每个类中都可以定义字段和方法,并通过不同的符号来表示这些成员的可访问级别

当然,我们的目的不是手写这样的PlantUML文本来绘制UML类图,我们希望能够有个程序,它可以根据给定的源程序代码,自动产生UML类图。

Roslyn的C#语言服务

根据GitHub中Roslyn项目的说明,Roslyn提供了开源的C#和Visual Basic编译器,并且提供了丰富的代码分析API,使得开发人员能够非常方便地开发.NET语言的代码分析工具。总体来说,Roslyn主要提供了以下NuGet包:

  • Microsoft.Net.Compilers:它包含了C#和Visual Basic的编译器
  • Microsoft.CodeAnalysis:它包含了代码分析API以及语言服务(Language Services)

或许大家对于Roslyn并不陌生,然而对于如何运用这套强大的语言平台却倍感疑惑。嗯,Roslyn是.NET语言的编译器基础,基于Apache 2.0开源,很强大,可是我们平时没有需要使用这些工具和库的需求啊,我们知道Visual Studio中的代码分析工具会基于Roslyn,可是除了代码分析,还可以在什么场景中使用呢?今天,我们就使用Roslyn的语言服务来为我们画UML类图。

PlantUML文本的自动生成

使用Roslyn的C#语言服务来生成UML类图,大致流程如下:

  1. 搜索指定目录的所有C#代码文件,这些文件通常都以.cs作为后缀名
  2. 使用Roslyn的C#语言服务,针对每个C#代码文件,逐一分析出其中的类型(类、接口等)以及每个类型下的成员(字段、属性、方法等)
  3. 将分析结果转化为PlantUML文本
  4. 通过某种工具,将PlantUML呈现为UML类图

搜索指定目录下的C#代码文件很简单,使用Directory.EnumerateFiles就可以了,接下来就是要把代码文件中的类型和成员都解析出来,并保存到一个数据模型中,然后,才可以根据这个数据模型来输出PlantUML的文本。这个过程其实也就是计算机语言相互转换的过程,比如你希望将C#语言代码转换成Java代码,那么,两者必然要基于同一个语言数据模型,比如,通用的表达式树可以描述所有编程语言中的表达式。在这里的例子中,我们可以将PlantUML看成是另一种编程语言(它其实本身也就是一种领域特定语言(DSL)),于是,我们目前的首要问题就是定义这个数据模型。

根据需要,我定义了如下的数据模型,用来保存C#代码解析后的信息:

test3

这个数据模型主体部分的设计如下:

  • 一个ClassDiagram类包含了一组BasicTypeRelationship,用来表达基本类型(类和接口)之间的关系;此外,还包含了一组类的声明以及一组接口的声明
  • 类和接口都继承于BasicType基类,同时包含了一组字段(Field)和一组方法(Method)的定义
  • Field和Method都继承于ClassMember
  • Method包含一组参数(Parameter)的定义

目前这个模型的定义还是非常简单的,并没有包含类似泛型、属性等的设计,不过有了这个基础的模型,今后扩展起来就很简单了。接下来就是通过Roslyn,将C#源代码转换成这个模型。

首先,我们需要添加Microsoft.CodeAnalysis.CSharp这个NuGet包,然后,依照访问者设计模式,实现一个CSharpSyntaxWalker,它会在遍历C#语法树的时候,根据访问的当前节点的类型来调用相应的方法,于是,我们的访问器则可以重载这些方法,然后构建上述数据模型。代码如下:

public class PlantUmlClassDiagramGenerator : CSharpSyntaxWalker
{
    public PlantUmlClassDiagramGenerator(string diagramName)
    {
        ClassDiagram = new ClassDiagram(diagramName);
    }

    public override void VisitClassDeclaration(ClassDeclarationSyntax node)
    {
        if (node.BaseList != null)
        {
            foreach (var baseType in node.BaseList.Types)
            {
                ClassDiagram.Relationships.Add(new BasicTypeRelationship
                {
                    Left = baseType.Type.ToString(),
                    Right = node.Identifier.ToString(),
                    Type = RelationshipType.Generalization
                });
            }
        }

        var clazz = new Class { Name = node.Identifier.ToString() };
        if (node.Modifiers.Any(SyntaxKind.AbstractKeyword))
        {
            clazz.IsAbstract = true;
        }

        if (node.Modifiers.Any(SyntaxKind.StaticKeyword))
        {
            clazz.IsStatic = true;
        }

        var propertyDeclarations = node.Members.Where(m => m is PropertyDeclarationSyntax)
            .Select(m => m as PropertyDeclarationSyntax);
        foreach (var propertyDeclaration in propertyDeclarations)
        {
            var field = new Field { Name = propertyDeclaration.Identifier.ToString(), Type = propertyDeclaration.Type.ToString() };
            field.AccessModifier = GetAccessModifier(propertyDeclaration.Modifiers);
            clazz.Fields.Add(field);
        }

        var fieldDeclarations = node.Members.Where(m => m is FieldDeclarationSyntax)
            .Select(m => m as FieldDeclarationSyntax);
        foreach (var fieldDeclaration in fieldDeclarations)
        {
            clazz.Fields.AddRange(CreateFields(fieldDeclaration));
        }

        var methodDeclarations = node.Members.Where(m => m is MethodDeclarationSyntax)
            .Select(m => m as MethodDeclarationSyntax);
        foreach(var methodDeclaration in methodDeclarations)
        {
            clazz.Methods.Add(CreateMethod(methodDeclaration));
        }

        ClassDiagram.Classes.Add(clazz);
    }

    private IEnumerable<Field> CreateFields(FieldDeclarationSyntax fieldDeclaration)
    {
        var type = fieldDeclaration.Declaration.Type.ToString();
        foreach (var variableDeclaration in fieldDeclaration.Declaration.Variables)
        {
            var field = new Field { Name = variableDeclaration.Identifier.ToString(), Type = type };
            field.IsStatic = fieldDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword);
            field.AccessModifier = GetAccessModifier(fieldDeclaration.Modifiers);

            yield return field;
        }
    }

    private Method CreateMethod(MethodDeclarationSyntax methodDeclaration)
    {
        var method = new Method { Name = methodDeclaration.Identifier.ToString() };
        method.IsAbstract = methodDeclaration.Modifiers.Any(SyntaxKind.AbstractKeyword);
        method.IsStatic = methodDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword);
        method.AccessModifier = GetAccessModifier(methodDeclaration.Modifiers);
        foreach (var parameterDeclaration in methodDeclaration.ParameterList.Parameters)
        {
            method.Parameters.Add(new Parameter { Name = parameterDeclaration.Identifier.ToString(), Type = parameterDeclaration.Type.ToString() });
        }
        method.Type = methodDeclaration.ReturnType.ToString();

        return method;
    }

    private AccessModifier GetAccessModifier(SyntaxTokenList modifiers)
    {
        if (modifiers.Any(SyntaxKind.PublicKeyword))
        {
            return AccessModifier.Public;
        }
        else if (modifiers.Any(SyntaxKind.ProtectedKeyword))
        {
            return AccessModifier.Protected;
        }
        else if (modifiers.Any(SyntaxKind.InternalKeyword))
        {
            return AccessModifier.Internal;
        }
        else
        {
            return AccessModifier.Private;
        }
    }

    public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node)
    {
        ClassDiagram.Interfaces.Add(new Interface { Name = node.Identifier.ToString() });
    }

    public ClassDiagram ClassDiagram { get; }
}

以下是调用代码:

static void Main(string[] args)
{
    const string SourcePath = @"C:\Users\daxne\source\repos\ConsoleApp10\ConsoleApp10\Sample";
    var csharpFiles = Directory.EnumerateFiles(SourcePath, "*.cs", SearchOption.AllDirectories);
    var walker = new PlantUmlClassDiagramGenerator("sample");
    foreach(var csharpFile in csharpFiles)
    {
        if (csharpFile.EndsWith(".designer.cs", StringComparison.InvariantCultureIgnoreCase))
        {
            continue;
        }

        var sourceCode = File.ReadAllText(csharpFile);
        var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
        walker.Visit(syntaxTree.GetRoot());
    }

    Console.WriteLine($"Classes: {walker.ClassDiagram.Classes.Count}");
    Console.WriteLine($"Interfaces: {walker.ClassDiagram.Interfaces.Count}");

    File.WriteAllText(@"C:\Users\daxne\Desktop\text.puml", walker.ClassDiagram.ToString());
}

最后输出的PlantUML文本如下:

@startuml sample
Person <|-- Student
Person <|-- Employee
Employee <|-- RegularEmployee
Employee <|-- Contractor
abstract class Person {
  + FirstName : string
  + LastName : string
  + MiddleInitial : string
}

class Student {
  + Identification : string
  +Register(registerDate: DateTime) : void
}

abstract class Employee {
  + EmployeeId : string
  + DocumentSigned : DateTime
}

class RegularEmployee {
  +Resign() : void
}

class Contractor {
  + ContractEndDate : DateTime
  +TerminateContract() : void
}

@enduml

PlantUML文本的图形化渲染

现在已经生成了PlantUML的文本,接下来就要将它渲染成UML类图。我推荐使用Visual Studio Code的PlantUML插件,不仅能够提供代码高亮功能,而且还可以实时预览渲染结果,非常方便。

image

在安装和使用PlantUML插件之前,请确保已经安装了以下组件:

  • Java 8
  • Graphviz

该插件还支持将渲染的UML图导出成各种格式的图片,在此就不多说明了。

总结

本文对PlantUML进行了简单的介绍,并介绍了如何通过.NET的Roslyn语言服务和代码分析API,实现类图的动态生成。PlantUML将UML图文本化,不仅有利于UML图的版本追踪和控制,而且在很多第三方的工具(比如Confluence)中都能够很方便地集成。而自动化生成UML图形的意义在于,从代码产生设计图变得更加方便,而且能够始终与代码设计保持一致。而另一方面,.NET Roslyn编译器服务本身也给开发者带来了更多C#、Visual Basic代码处理的机遇,我们可以使用这样的服务来帮助我们做更多的事情,简化我们的日常工作。

(总访问量:1,328;当日访问量:1)

4 thoughts on “使用Roslyn的C#语言服务实现UML类图的自动生成

  1. sPhinX

    能介绍下这套“基于Windows Forms的开发框架”吗?感觉现在Windows Forms越来越不受待见,唉

    Reply
  2. sPhinX

    评论不是实时更新的吗?刚才的评论看不到,想重发一次还提示重复了。

    Reply
    1. daxnet Post author

      不好意思,因为打开了留言评审,所以没有直接显示出来,现在处理完了就好了。
      Windows Forms现在确实应用比较少,因此,我也没有完全抽出时间来贡献一个Windows Forms的开发库,但是如果大家有需要,我可以做这个事情。
      然而,微软从来没有放弃过Windows Forms,即使是当今.NET Core的时代。在.NET Core 3.0中,Windows Forms的所有BCL都会被支持,而且性能会得到很大的提升。此外,现有的Windows Forms应用程序,源代码可以不做任何修改,直接编译面向.NET Core 3运行时的可执行文件,编译过程还可以将.NET Core的运行库一并打包到exe中,因此,运行exe的电脑是可以不安装任何版本的.NET Framework的。.NET Core 3.0应该会在2019年春季发布。拭目以待吧。

      Reply

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据