2002 年,我编写了一本面向本科生的面向对象设计和模式教科书 [Hor05]。与许多书籍一样,这本书的创作动机是对传统课程的失望。通常,计算机科学专业的学生在他们的第一门编程课程中学习如何设计单个类,然后直到他们高年级的软件工程课程才接受进一步的面向对象设计培训。在该课程中,学生们匆匆忙忙地学习几周的 UML 和设计模式,这只能带来一种知识的假象。我的书支持一个为具有 Java 编程和基本数据结构背景(通常来自基于 Java 的 CS1/CS2 课程)的学生开设的学期课程。本书在熟悉的情况下涵盖了面向对象设计原则和设计模式。例如,装饰器设计模式是在 Swing JScrollPane
的上下文中介绍的,希望这个例子比经典的 Java 流示例更容易记住。
图 22.1:Violet 对象图
我需要一本针对本书的 UML 简化版本:类图、序列图和对象图的变体,该变体显示 Java 对象引用(图 22.1)。我还希望学生能够绘制自己的图表。但是,Rational Rose 等商业产品不仅价格昂贵,而且学习和使用起来也很麻烦 [Shu05],而当时可用的开源替代方案过于有限或存在 bug 而无法使用1,其中图表由文本声明而不是更常见的点击式界面指定。}。特别是,ArgoUML 中的序列图存在严重问题。
我决定尝试编写一个最简单的编辑器,它 (a) 对学生有用,并且 (b) 是一个可扩展框架的示例,学生可以理解和修改它。因此,Violet 诞生了。
Violet 是一款轻量级的 UML 编辑器,面向需要快速生成简单 UML 图表的学生、教师和作者。它非常易于学习和使用。它绘制类图、序列图、状态图、对象图和用例图。(此后,其他图表类型也得到了贡献。)它是开源且跨平台的软件。在核心部分,Violet 使用了一个简单但灵活的图形框架,充分利用了 Java 2D 图形 API。
Violet 用户界面故意保持简单。您无需经历一系列冗长的对话框来输入属性和方法。相反,您只需将它们键入文本字段中。只需点击几下鼠标,您就可以快速创建美观且实用的图表。
Violet 并非试图成为一个工业级的 UML 程序。以下是一些 Violet 不具备的功能
(尝试解决其中一些限制可以成为很好的学生项目。)
当 Violet 在设计师中获得了一批追随者,他们想要一个比餐巾纸更高级但又比工业级 UML 工具更简单的工具时,我在 SourceForge 上根据 GNU 通用公共许可证发布了代码。从 2005 年开始,Alexandre de Pellegrin 加入该项目,提供了 Eclipse 插件和更美观的界面。从那以后,他进行了许多架构更改,现在是该项目的主要维护者。
在这篇文章中,我将讨论 Violet 中一些最初的架构选择及其演变。文章的一部分侧重于图形编辑,但其他部分——例如 JavaBeans 属性和持久性、Java WebStart 和插件架构的使用——应该具有普遍的兴趣。
Violet 基于一个通用的图形编辑框架,可以渲染和编辑任意形状的节点和边。Violet UML 编辑器具有用于类、对象、激活条(在序列图中)等的节点,以及用于 UML 图表中各种边形状的边。图形框架的另一个实例可能显示实体关系图或铁路图。
图 22.2:编辑器框架的一个简单实例
为了说明该框架,让我们考虑一个用于非常简单图形的编辑器,该编辑器具有黑色和白色圆形节点和直线边(图 22.2)。SimpleGraph
类为节点和边类型指定原型对象,说明了原型模式
public class SimpleGraph extends AbstractGraph { public Node[] getNodePrototypes() { return new Node[] { new CircleNode(Color.BLACK), new CircleNode(Color.WHITE) }; } public Edge[] getEdgePrototypes() { return new Edge[] { new LineEdge() }; } }
原型对象用于绘制图 22.2顶部节点和边按钮。每当用户向图形添加新的节点或边实例时,就会克隆它们。Node
和 Edge
是具有以下关键方法的接口
getShape
方法,该方法返回节点或边形状的 Java2D Shape
对象。Edge
接口具有返回边开始和结束节点的方法。Node
接口类型中的 getConnectionPoint
方法计算节点边界上的最佳连接点(参见图 22.3)。Edge
接口的 getConnectionPoints
方法返回边的两个端点。此方法需要绘制标记当前选定边的“抓手”。图 22.3:在节点形状的边界上查找连接点
便利类 AbstractNode
和 AbstractEdge
实现了许多这些方法,而类 RectangularNode
和 SegmentedLineEdge
提供了矩形节点(带有标题字符串)和由线段组成的边的完整实现。
在我们的简单图形编辑器的情况下,我们需要提供子类 CircleNode
和 LineEdge
,它们提供 draw
方法、contains
方法以及描述节点边界形状的 getConnectionPoint
方法。代码如下所示,图 22.4 显示了这些类的类图(当然,是用 Violet 绘制的)。
public class CircleNode extends AbstractNode { public CircleNode(Color aColor) { size = DEFAULT_SIZE; x = 0; y = 0; color = aColor; } public void draw(Graphics2D g2) { Ellipse2D circle = new Ellipse2D.Double(x, y, size, size); Color oldColor = g2.getColor(); g2.setColor(color); g2.fill(circle); g2.setColor(oldColor); g2.draw(circle); } public boolean contains(Point2D p) { Ellipse2D circle = new Ellipse2D.Double(x, y, size, size); return circle.contains(p); } public Point2D getConnectionPoint(Point2D other) { double centerX = x + size / 2; double centerY = y + size / 2; double dx = other.getX() - centerX; double dy = other.getY() - centerY; double distance = Math.sqrt(dx * dx + dy * dy); if (distance == 0) return other; else return new Point2D.Double( centerX + dx * (size / 2) / distance, centerY + dy * (size / 2) / distance); } private double x, y, size, color; private static final int DEFAULT_SIZE = 20; } public class LineEdge extends AbstractEdge { public void draw(Graphics2D g2) { g2.draw(getConnectionPoints()); } public boolean contains(Point2D aPoint) { final double MAX_DIST = 2; return getConnectionPoints().ptSegDist(aPoint) < MAX_DIST; } }
图 22.4:简单图形的类图
总之,Violet 提供了一个用于生成图形编辑器的简单框架。要获取编辑器实例,请定义节点和边类,并在图形类中提供返回原型节点和边对象的方法。
当然,还有其他可用的图形框架,例如 JGraph [Ald02] 和 JUNG2。但是,这些框架要复杂得多,它们提供的是绘制图形的框架,而不是绘制图形的应用程序。
在客户端 Java 的黄金时代,开发了 JavaBeans 规范,以便为在可视化 GUI 生成器环境中编辑 GUI 组件提供可移植的机制。其愿景是,第三方 GUI 组件可以放置到任何 GUI 生成器中,其属性可以在与标准按钮、文本组件等相同的方式下进行配置。
Java 没有原生属性。相反,JavaBeans 属性可以作为 getter 和 setter 方法对被发现,或者使用配套的 BeanInfo 类进行指定。此外,可以为可视化编辑属性值指定属性编辑器。JDK 甚至包含一些基本的属性编辑器,例如 java.awt.Color
类型。
Violet 框架充分利用了 JavaBeans 规范。例如,CircleNode
类可以通过提供两个方法来公开颜色属性
public void setColor(Color newValue) public Color getColor()
无需进一步操作。图形编辑器现在可以编辑圆形节点的颜色(图 22.5)。
图 22.5:使用默认 JavaBeans 颜色编辑器编辑圆形颜色
就像任何编辑器程序一样,Violet 必须将用户的创作保存到文件中并在以后重新加载它们。我查看了 XMI 规范3,该规范旨在作为 UML 模型的通用交换格式。我发现它笨拙、混乱且难以使用。我认为我不是唯一一个——XMI 即使在最简单的模型中也因互操作性差而闻名 [PGL+05]。
我考虑过简单地使用 Java 序列化,但很难读取其实现已随时间推移而发生更改的已序列化对象的旧版本。JavaBeans 架构师也预料到了这个问题,他们为长期持久性开发了一种标准的 XML 格式4。Java 对象——在 Violet 的情况下,是 UML 图表——被序列化为一系列用于构造和修改它的语句。以下是一个示例
<?xml version="1.0" encoding="UTF-8"?> <java version="1.0" class="java.beans.XMLDecoder"> <object class="com.horstmann.violet.ClassDiagramGraph"> <void method="addNode"> <object id="ClassNode0" class="com.horstmann.violet.ClassNode"> <void property="name">…</void> </object> <object class="java.awt.geom.Point2D$Double"> <double>200.0</double> <double>60.0</double> </object> </void> <void method="addNode"> <object id="ClassNode1" class="com.horstmann.violet.ClassNode"> <void property="name">…</void> </object> <object class="java.awt.geom.Point2D$Double"> <double>200.0</double> <double>210.0</double> </object> </void> <void method="connect"> <object class="com.horstmann.violet.ClassRelationshipEdge"> <void property="endArrowHead"> <object class="com.horstmann.violet.ArrowHead" field="TRIANGLE"/> </void> </object> <object idref="ClassNode0"/> <object idref="ClassNode1"/> </void> </object> </java>
当 XMLDecoder
类读取此文件时,它将执行这些语句(为简单起见,省略了包名)。
ClassDiagramGraph obj1 = new ClassDiagramGraph(); ClassNode ClassNode0 = new ClassNode(); ClassNode0.setName(…); obj1.addNode(ClassNode0, new Point2D.Double(200, 60)); ClassNode ClassNode1 = new ClassNode(); ClassNode1.setName(…); obj1.addNode(ClassNode1, new Point2D.Double(200, 60)); ClassRelationShipEdge obj2 = new ClassRelationShipEdge(); obj2.setEndArrowHead(ArrowHead.TRIANGLE); obj1.connect(obj2, ClassNode0, ClassNode1);
只要构造函数、属性和方法的语义没有更改,较新版本的程序就可以读取由较旧版本生成的文件。
生成此类文件非常简单。编码器会自动枚举每个对象的属性,并为与默认值不同的属性值写入 setter 语句。大多数基本数据类型由 Java 平台处理;但是,我必须为 Point2D
、Line2D
和 Rectangle2D
提供特殊的处理程序。最重要的是,编码器必须知道图形可以序列化为一系列 addNode
和 connect
方法调用
encoder.setPersistenceDelegate(Graph.class, new DefaultPersistenceDelegate() { protected void initialize(Class<?> type, Object oldInstance, Object newInstance, Encoder out) { super.initialize(type, oldInstance, newInstance, out); AbstractGraph g = (AbstractGraph) oldInstance; for (Node n : g.getNodes()) out.writeStatement(new Statement(oldInstance, "addNode", new Object[] { n, n.getLocation() })); for (Edge e : g.getEdges()) out.writeStatement(new Statement(oldInstance, "connect", new Object[] { e, e.getStart(), e.getEnd() })); } });
一旦配置了编码器,保存图形就变得像这样简单
encoder.writeObject(graph);
由于解码器只是执行语句,因此它不需要配置。图形只需使用以下命令读取
Graph graph = (Graph) decoder.readObject();
这种方法在 Violet 的众多版本中都非常有效,只有一个例外。最近的一次重构更改了一些包名,从而破坏了向后兼容性。一种选择是将类保留在原始包中,即使它们不再与新的包结构匹配。相反,维护者提供了一个 XML 变换器,用于在读取旧文件时重写包名。
Java WebStart 是一种从 Web 浏览器启动应用程序的技术。部署程序发布一个 JNLP 文件,该文件触发浏览器中的辅助应用程序,该应用程序下载并运行 Java 程序。该应用程序可以进行数字签名,在这种情况下,用户必须接受证书,或者它可以不进行签名,在这种情况下,程序将在比 applet 沙箱稍微宽松的沙箱中运行。
我认为最终用户不能也不应该被信任来判断数字证书及其安全含义的有效性。Java 平台的优势之一是其安全性,我认为发挥其优势非常重要。
Java WebStart 沙箱功能强大,足以使用户能够完成有用的工作,包括加载和保存文件以及打印。这些操作从用户的角度来看是安全且方便地处理的。系统会提醒用户应用程序想要访问本地文件系统,然后选择要读取或写入的文件。应用程序仅接收流对象,而没有机会在文件选择过程中窥视文件系统。
令人恼火的是,当应用程序在WebStart下运行时,开发人员必须编写自定义代码来与FileOpenService
和FileSaveService
交互,更令人恼火的是,没有WebStart API调用可以确定应用程序是否由WebStart启动。
类似地,保存用户首选项必须以两种方式实现:当应用程序正常运行时使用Java首选项API,或者当应用程序在WebStart下运行时使用WebStart首选项服务。另一方面,打印对应用程序程序员来说是完全透明的。
Violet在这些服务之上提供了简单的抽象层,以简化应用程序程序员的工作。例如,以下是打开文件的方法
FileService service = FileService.getInstance(initialDirectory); // detects whether we run under WebStart FileService.Open open = fileService.open(defaultDirectory, defaultName, extensionFilter); InputStream in = open.getInputStream(); String title = open.getName();
FileService.Open
接口由两个类实现:一个JFileChooser
或JNLP FileOpenService
的包装器。
JNLP API本身没有这样的便利功能,但该API在其生命周期中很少受到关注,并且被广泛忽略。大多数项目只是为其WebStart应用程序使用自签名证书,这不会给用户带来任何安全性。这很可惜——开源开发人员应该将JNLP沙箱作为尝试项目的无风险方式。
Violet大量使用Java2D库,它是Java API中鲜为人知的瑰宝之一。每个节点和边都有一个getShape
方法,该方法生成一个java.awt.Shape
,它是所有Java2D形状的通用接口。此接口由矩形、圆形、路径及其并集、交集和差集实现。GeneralPath
类对于创建由任意线段和二次/三次曲线段组成的形状(例如直线和曲线箭头)很有用。
为了了解Java2D API的灵活性,请考虑以下在AbstractNode.draw
方法中绘制阴影的代码
Shape shape = getShape(); if (shape == null) return; g2.translate(SHADOW_GAP, SHADOW_GAP); g2.setColor(SHADOW_COLOR); g2.fill(shape); g2.translate(-SHADOW_GAP, -SHADOW_GAP); g2.setColor(BACKGROUND_COLOR); g2.fill(shape);
几行代码可以为任何形状生成阴影,即使是开发人员以后可能添加的形状。
当然,Violet可以以javax.imageio
包支持的任何格式保存位图图像;也就是说,GIF、PNG、JPEG等。当我的出版商向我索要矢量图像时,我注意到了Java 2D库的另一个优势。当您打印到PostScript打印机时,Java2D操作将转换为PostScript矢量绘图操作。如果打印到文件,则结果可以被ps2eps
等程序使用,然后导入到Adobe Illustrator或Inkscape中。以下是代码,其中comp
是Swing组件,其paintComponent
方法绘制图形
DocFlavor flavor = DocFlavor.SERVICE_FORMATTED.PRINTABLE; String mimeType = "application/postscript"; StreamPrintServiceFactory[] factories; StreamPrintServiceFactory.lookupStreamPrintServiceFactories(flavor, mimeType); FileOutputStream out = new FileOutputStream(fileName); PrintService service = factories[0].getPrintService(out); SimpleDoc doc = new SimpleDoc(new Printable() { public int print(Graphics g, PageFormat pf, int page) { if (page >= 1) return Printable.NO_SUCH_PAGE; else { double sf1 = pf.getImageableWidth() / (comp.getWidth() + 1); double sf2 = pf.getImageableHeight() / (comp.getHeight() + 1); double s = Math.min(sf1, sf2); Graphics2D g2 = (Graphics2D) g; g2.translate((pf.getWidth() - pf.getImageableWidth()) / 2, (pf.getHeight() - pf.getImageableHeight()) / 2); g2.scale(s, s); comp.paint(g); return Printable.PAGE_EXISTS; } } }, flavor, null); DocPrintJob job = service.createPrintJob(); PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet(); job.print(doc, attributes);
一开始,我担心使用通用形状可能会导致性能下降,但事实证明并非如此。裁剪效果很好,以至于实际上只执行更新当前视口所需的那些形状操作。
大多数GUI框架都具有一些应用程序的概念,该应用程序管理一组处理菜单、工具栏、状态栏等文档。但是,这从未成为Java API的一部分。JSR 2965应该为Swing应用程序提供一个基本框架,但目前处于非活动状态。因此,Swing应用程序作者有两个选择:重新发明大量轮子或基于第三方框架。在编写Violet时,应用程序框架的主要选择是Eclipse和NetBeans平台,当时这两个平台都显得过于重量级。(现在,有更多选择,其中包括JSR 296的分支,例如GUTS6。)因此,Violet不得不重新发明处理菜单和内部框架的机制。
在Violet中,您可以在属性文件中指定菜单项,如下所示
file.save.text=Save file.save.mnemonic=S file.save.accelerator=ctrl S file.save.icon=/icons/16x16/save.png
一个实用程序方法根据前缀(此处为file.save
)创建菜单项。后缀.text
、.mnemonic
等,是现在所谓的“约定优于配置”。使用资源文件来描述这些设置显然优于使用API调用设置菜单,因为它允许轻松进行本地化。我在另一个开源项目GridWorld环境(用于高中计算机科学教育)7中重新使用了该机制。
像Violet这样的应用程序允许用户打开多个“文档”,每个文档包含一个图形。当Violet首次编写时,多文档界面(MDI)仍然被普遍使用。使用MDI,主框架具有菜单栏,每个文档的视图都显示在一个具有标题但没有菜单栏的内部框架中。每个内部框架都包含在主框架中,并且用户可以调整其大小或最小化。有一些操作可以级联和平铺窗口。
许多开发人员不喜欢MDI,因此这种样式的用户界面已经过时。有一段时间,单文档界面(SDI)被认为是优越的,在SDI中,应用程序显示多个顶级框架,这可能是因为这些框架可以使用主机操作系统的标准窗口管理工具进行操作。当很明显拥有大量顶级窗口并不是那么好的时候,选项卡式界面开始出现,其中多个文档再次包含在一个框架中,但现在都以全尺寸显示,并且可以通过选项卡进行选择。这不允许用户并排比较两个文档,但似乎已经胜出。
Violet的原始版本使用MDI界面。Java API具有内部框架功能,但我必须添加对平铺和平铺的支持。Alexandre切换到选项卡式界面,该界面在一定程度上得到了Java API的更好支持。理想情况下,应该有一个应用程序框架,其中文档显示策略对开发人员透明,并且用户可以选择。
Alexandre还添加了对侧边栏、状态栏、欢迎面板和启动屏幕的支持。所有这些都理想情况下应该是Swing应用程序框架的一部分。
实现多级撤消/重做似乎是一项艰巨的任务,但Swing撤消包([Top00],第9章)提供了良好的架构指导。一个UndoManager
管理一个UndoableEdit
对象的堆栈。它们每个都有一个undo
方法,用于撤消编辑操作的效果,以及一个redo
方法,用于撤消撤消(即执行原始编辑操作)。CompoundEdit
是一系列UndoableEdit
操作,应整体撤消或重做。建议您定义小的、原子的编辑操作(例如在图形的情况下添加或删除单个边或节点),并在必要时将其分组为复合编辑。
一个挑战是定义一小组原子操作,每个操作都可以轻松撤消。在Violet中,它们是
这些操作中的每一个都有一个明显的撤消操作。例如,添加节点的撤消是删除节点。移动节点的撤消是按相反的向量移动它。
图22.6:撤消操作必须撤消模型中的结构更改
请注意,这些原子操作不等于用户界面中的操作或用户界面操作调用的Graph
接口的方法。例如,考虑图22.6中的序列图,并假设用户将鼠标从激活栏拖动到右侧的生命线。当释放鼠标按钮时,将调用以下方法
public boolean addEdgeAtPoints(Edge e, Point2D p1, Point2D p2)
该方法添加了一条边,但它也可能执行其他操作,如参与的Edge
和Node
子类所指定。在这种情况下,将在右侧的生命线上添加一个激活栏。撤消操作还需要删除该激活栏。因此,模型(在我们的例子中,图形)需要记录需要撤消的结构更改。仅仅收集控制器操作是不够的。
正如Swing撤消包所设想的那样,每当发生结构编辑时,图形、节点和边类应向UndoManager
发送UndoableEditEvent
通知。Violet具有更通用的设计,其中图形本身管理以下接口的侦听器
public interface GraphModificationListener { void nodeAdded(Graph g, Node n); void nodeRemoved(Graph g, Node n); void nodeMoved(Graph g, Node n, double dx, double dy); void childAttached(Graph g, int index, Node p, Node c); void childDetached(Graph g, int index, Node p, Node c); void edgeAdded(Graph g, Edge e); void edgeRemoved(Graph g, Edge e); void propertyChangedOnNodeOrEdge(Graph g, PropertyChangeEvent event); }
框架将一个侦听器安装到每个图形中,该侦听器是到撤消管理器的一个桥梁。为了支持撤消,向模型添加泛型侦听器支持设计过度——图形操作可以直接与撤消管理器交互。但是,我还想支持一项实验性的协作编辑功能。
如果您想在您的应用程序中支持撤消/重做,请仔细考虑模型(而不是用户界面)中的原子操作。在模型中,当结构发生变化时触发事件,并允许Swing撤消管理器收集和分组这些事件。
对于熟悉2D图形的程序员来说,向Violet添加新的图表类型并不困难。例如,活动图是由第三方贡献的。当需要创建铁路图和ER图时,我发现编写Violet扩展比使用Visio或Dia更快。(每个图表类型都需要一天时间才能实现。)
这些实现不需要了解完整的Violet框架。只需要图形、节点和边接口以及便利实现。为了使贡献者更容易与框架的演变脱钩,我设计了一个简单的插件架构。
当然,许多程序都有一个插件架构,许多都相当复杂。当有人建议Violet应该支持OSGi时,我打了个寒颤,而是实现了最简单有效的方法。
贡献者只需使用其图形、节点和边实现生成一个JAR文件,并将其放入plugins
目录中。当Violet启动时,它会使用Java ServiceLoader
类加载这些插件。该类旨在加载诸如JDBC驱动程序之类的服务。ServiceLoader
加载承诺提供实现给定接口(在我们的例子中,是Graph
接口)的类的JAR文件。
每个JAR文件都必须有一个子目录META-INF/services
,其中包含一个文件,其名称是接口的全限定类名(例如com.horstmann.violet.Graph
),并且其中包含所有实现类的名称,每行一个。ServiceLoader
为插件目录构建一个类加载器,并加载所有插件
ServiceLoader<Graph> graphLoader = ServiceLoader.load(Graph.class, classLoader); for (Graph g : graphLoader) // ServiceLoader<Graph> implements Iterable<Graph> registerGraph(g);
这是标准Java的一个简单但有用的功能,您可能会发现它对您自己的项目很有价值。
像许多开源项目一样,Violet诞生于未满足的需求——以最少的麻烦绘制简单的UML图。Violet得益于Java SE平台惊人的广度,并且借鉴了该平台中各种各样的技术。在本文中,我描述了Violet如何使用Java Bean、长期持久性、Java Web Start、Java 2D、Swing撤消/重做以及服务加载器功能。这些技术并不总是像Java和Swing的基础知识那样为人所知,但它们可以极大地简化桌面应用程序的架构。它们使我作为最初的唯一开发人员,能够在几个月兼职工作中开发出一个成功的应用程序。依赖这些标准机制也使其他人更容易改进Violet并将其中的一部分提取到他们自己的项目中。
http://jung.sourceforge.net
http://www.omg.org/technology/documents/formal/xmi.htm
http://jcp.org/en/jsr/detail?id=57
http://jcp.org/en/jsr/detail?id=296
http://kenai.com/projects/guts
http://horstmann.com/gridworld