在2014年的Build开发者大会上,微软研究院(Microsoft Research)公开了一个叫做“奥尔良”的项目。这篇博客的目的就是对这个项目做一个初步的介绍,并引导感兴趣的读者快速上手进行体验。这篇文章并不是要介绍这个项目的方方面面,而是着重解释这个项目为什么会存在,以及它可能的应用场景。关于项目的具体内容,请参阅微软研究院网站(英文)。
系统的可缩放性架构
要理清奥尔良项目的由来,我们还得从系统的可缩放性(Scalability)设计开始。目前大多数的云应用项目都会采用n层体系结构,将应用程序划分为表现层,中间层(业务层)和数据层。对于中间层的可缩放性设计,一个十分通用的方法就是采用无态化(Stateless)设计。也就是说,中间层的业务逻辑部件不保有本地(例如本机内存或本机磁盘)的状态。这种无态化的设计对于中间层的可缩放性至关重要,因为:一、便于系统的负载平衡。因为业务层是无态的,任意相邻的两个请求可以被分配到不同的服务器来处理而不会造成状态的冲突。二、便于中间层的动态缩放。用户可以随时增添或减少中间层服务器的数量来调节系统的吞吐能力,而无需担心对既有服务实例造成影响。三、便于系统的容错转移。由于中间层是无态的,一个失效的服务实例可以随时被替换以确保系统的可用性。
但是,无态化的设计也带来一定的问题,其中首当其冲的就是系统的性能。由于中间层要不断与数据层交流,数据层的吞吐能力往往就成了系统的瓶颈。虽然中间层在理论上可以无限缩放,但是由于数据层的限制,这种无限缩放是无法达成的。此外,由于数据层必定要进行数据I/O的操作,其性能与在内存中运行的中间层有着数量级的差别。调和这种矛盾的通用方法,就是应用缓存。缓存将中间层所需要的状态信息置于分布的内存集群之中以保证中间层的性能。这也就意味着程序的数据层需要加入对缓存的处理,例如缓存内容的更新、与数据库的协调、处理并发请求等等。这些无疑都增加了系统的复杂程度。
让我们对缓存的作用再做一些深入思考:缓存对于中间层的意义在哪里?实际上,缓存对于中间层来说,就是提供了一个快速存储状态的机制。换句话说,与访问数据库的漫漫长路相比,缓存把状态和中间层的距离缩短了,从而中间层可以以访问本地数据的方式和频度来访问这些状态。但是,缓存是不是达到这个效果的唯一途径呢?这个问题一会儿继续讨论。
面向对象的设计方式
笔者刚开始学编程的时候还是面向过程当道的时候。记得当初在学校机房第一次接触面向对象的理念的时候,我印象最深的就是这种新奇的设计方式与现实世界中的实体有着如此紧密的映射关系。面向对象对行为和状态的封装为解决现实问题提供了一个强大而自然的建模语言。这种对世界的分解、抽象能力使得面向对象迅速成为业界的主流技术,并在实际应用中体现出了其强大的生命力。很多通过面向过程很难解决的问题都迎刃而解,程序员们也可以创建功能越来越复杂,规模越来越大的程序。
这里为什么要提这个呢?目的在于重新考量中间层的无态化设计问题。中间层的无态化,实际上是对行为和状态的一种割裂。对于我的一个抽象实体来说,它的行为体现在中间层,而它的状态体现在数据层。更为重要的是,其状态的维护是由数据层组件代理的。这实际上违反了对面向对象数据封装的原则。那么,我们如何能回到面向对象设计的本态呢?这是第二个问题。
Actor模型
Actor模型为上述两个问题提供了一个很好的答案。Actor模型是用于分布系统设计的一种设计模式。在Actor模型下,系统由一系列Actor构成。一个Actor能够收发消息,自我决策,驱动自身状态,并可以创建其它的Actor。Actor的执行方式是并行的,Actor之间不共享状态,而是通过消息来交互。用Actor模型可以对很多现实问题进行很好的描述,特别是对于大型并发系统的设计有其独特的优势。很多软件系统要维护与客观实体相对应的实例,例如游戏玩家,客户订单,物联网中的传感器等等。这些实例在现实生活中是相对独立的,它们之间是很自然的并发关系。Actor模型对这种众多的并发实体提供了一个很好的抽象。特别地,Actor模型对于系统并发性的一个突出贡献就是避免部件间的互锁,而互锁问题一直是并发设计的大敌。Actor对自身状态的封装很好地规避了两个部件共享状态的场景。
Actor模型的一个严重问题就是其实施的复杂性。虽然一些支持Actor模型的语言已经存在(例如Erlang和Scala)程序员们还是要在Actor的生存周期管理、寻址和通讯等等方面进行大量架构层次上的代码。这种复杂性在很大程度上也限制了Actor模型的广泛应用。
限于篇幅限制,笔者在这里只是对Actor模型做了一个粗浅介绍,感兴趣的读者请参考相关文献。下面,我们就要正式谈奥尔良项目了。
奥尔良项目的核心思想
奥尔良项目的核心思想,在于回归面向对象的原则,采用Actor模型来设计中间层。在奥尔良项目运行环境下,中间层不再是一组提供行为的部件,而是分散的Actor(奥尔良术语中叫做Grain)。奥尔良提供了Actor的宿主环境(称作Silo),并确保每个Actor的执行环境都是单线程的。也就是说,你不需要管理Actor的生命周期,不用处理并发、加锁等问题,也不用管理Actor的状态。书写一个Actor的代码,基本上就是声明一个普通的类的过程。尽管所有的行为需要被声明为异步的,随着.Net对于异步代码的简化,书写起来也是毫不费力的。
通过使用奥尔良项目,中间层被解析成大量的、并发的Actors。奥尔良负责这些Actor的激活、负载平衡、容错转移和恢复、分区(未来版本)、状态同步等等诸多事宜。而对于开发者来说,所有的开发任务都是书写和项目逻辑实体相关的类,没有太多额外的架构层次上的代码工作。换言之,在架构层面奥尔良项目提供了很多对Actor模型的简化。笔者在这里不能一一例举奥尔良项目的功能,但是希望读者可以从本文末尾的实例中获得一些切身体会。
体验奥尔良项目
下面我们就动手在奥尔良项目上做一个基本的Hello World项目。注意奥尔良项目现在处于早期公共预览状态,所以下面笔者介绍的具体步骤很可能改变。
- 下载奥尔良项目SDK: https://connect.microsoft.com/VisualStudio/Downloads/DownloadDetails.aspx?DownloadID=52747。
- 执行Orleans_setup.msi。缺省的安装路径是C:\Microsoft Codename Orleans SDK v0.9。
- 在Visual Studio中(笔者使用的是2013 Ultimate),使用Visual C# -> Orleans Grain Interface Collection模板创建一个名为HelloWorldGrainInterfaces的项目。在奥尔良项目中,Actor称作为Grain。要创建一个Grain,你需要定义Grain的接口,然后提供Grain的实施。我们这一步是定义一个包含我们项目中所有Grain接口的集合。
- 将缺省的IGrain1改名为IUser,并定义如下接口。这个接口定义了一个SayHello行为。注意奥尔良项目需要所有的行为都是异步的。
- public interface IUser : Orleans.IGrain
- {
- Task<string> SayHello(string name);
- }
- 在同一个解决方案中,使用Visual C# -> Orleans Grain Class Collection模板创建一个名为HelloWordGrains的新项目。在新项目中引用我们刚刚定义的接口项目。
- 将缺省的Grain1类改名为User,并实施IUser接口:
- public class User : Orleans.GrainBase, IUser
- {
- public Task<string> SayHello(string name)
- {
- return Task.FromResult<string>("Hello, " + name);
- }
- }
- 在同一个解决方案中,使用Visual C# -> Orleans Dev/Test Host模板创建一个新的HelloWorld项目。这个项目模板提供了一个用于测试Orleans的独立宿主环境。在新项目中引用我们刚刚定义的两个项目。
- 修改Program.cs,插入下列代码中粗体的两行:
- Console.WriteLine("Orleans Silo is running.\nPress Enter to terminate...");
- <strong>var user = HelloWorldGrainInterfaces.UserFactory.GetGrain(0);
- Console.WriteLine(user.SayHello("Haishi").Result);
- </strong>Console.ReadLine();
- 执行程序,观察User Grain激活和执行的情况:
- 恭喜!你刚刚完成了第一个Orleans上的项目!下面我们把程序扩展一下来体验状态管理。首先,在接口集合项目中添加一个IUserState接口来描述用户状态。这里我们假定用户的状态是这个用户已经发出的问候。注意你不需要实施这个接口,奥尔良项目会自动生成这个接口的实施:
- public interface IUserState: Orleans.IGrainState
- {
- List<string> Greetings { get; set; }
- }
- 扩展IUser接口以提供返回以往问候的行为(ListGreetings):
- public interface IUser : Orleans.IGrain
- {
- Task<string> SayHello(string name);
- Task<List<string>> ListGreetings();
- }
- 修改User类的代码:
- [StorageProvider(ProviderName = "MemoryStore")]
- public class User : Orleans.GrainBase<IUserState>, IUser
- {
- public async Task<string> SayHello(string name)
- {
- string message = "Hello, " + name;
- this.State.Greetings.Add(message);
- await this.State.WriteStateAsync();
- return message;
- }
- public Task<List<string>> ListGreetings()
- {
- return Task.FromResult<List<string>>(this.State.Greetings);
- }
- }
- 需要注意的几点:一、User的基类已改成GrainBase<T>。这个基类提供了有状态Grain的基本实施。二、SayHello方法在返回问候前更新了User自身的状态。你可以看到这里我只是简单地向一个字符串列表中添加了内容而不需要进行任何加锁的操作。最后,ListGreetings()方法返回以往问候的列表。另外,该类上还标记了StorageProvider属性,用以指定将状态保存至何种数据源。数据源定义在DevTestServerConfiguraiton.xml文件中(如下例所示)。这里我选用的是缺省的基于内存的数据源。
- <Globals>
- <StorageProviders>
- <Provider Type="Orleans.Storage.MemoryStorage" Name="MemoryStore" />
- ....
- </StorageProviders>
- ....
- </Globals>
- 修改Program.cs,反复调用User几次。
- Console.WriteLine(user.SayHello("Haishi").Result);
- Console.WriteLine(user.SayHello("Jack").Result);
- Console.WriteLine(user.SayHello("Tom").Result);
- 上述代码中我使用的是一个user实例。实际上你可以反复地调用GetGrain方法来重新激活相同Grain,只要你给的标识符是同一个。
- 再次运行程序,你可以观察到用户状态的保持:
总结
在本文中笔者对微软的奥尔良项目做了一个扼要的解释,并提供了一个完整的例程。