ASP.NET 工作流
支持长时间运行操作的 Web 应用程序
Michael Kennedy
代码下载位置:MSDN 代码库
在线浏览代码
在线浏览代码
本文将介绍以下内容:
|
本文使用了以下技术: Windows Workflow Foundation、ASP.NET |
人们经常要求软件开发人员构建可支持长时间运行操作的 Web 应用程序。在线商店的结账过程就是一个例子,它只需数分钟即可完成。尽管依照某些标准这就是一个长时间运行操作,但我将在本文中探讨一个尺度完全不同的长时间运行操作:需持续数天、数周,甚至数月才可完成的操作。此类操作的一个示例是职位的申请过程,它涉及多人之间的交互以及众多实际文档的交换。
首先,我们从 ASP.NET 角度来考虑一个较为良性的问题:您需要为在线商店的结账操作构建一个解决方案。由于其持续时间的特殊性,我们需对此解决方案特别考虑一些事项。例如,您可能会选择在某个 ASP.NET 会话中存储购物车数据。您甚至可以选择将该会话状态移动到进程外状态服务器或数据库,以更新站点和负载平衡。即便如此,您会发现轻松解决此问题所需的全部工具均由 ASP.NET 自身提供。
但如果操作的持续时间变得比典型的 ASP.NET 会话持续时间(20 分钟)更长,或需要多名参与者(就象刚才的聘用示例)时,ASP.NET 不能提供充分的支持。您也许还记得 ASP.NET 工作进程会在空闲时自动关闭并定期自身回收。这会导致长时间运行操作出现严重错误,因为这些进程中保存的状态将会丢失。
设想一下,您将需要在单个进程内部托管这些运行时间超长的操作。显然,出于上述原因,ASP.NET 工作进程并不适用于它们。因此需要创建一个窗口服务,它的职责就是执行这些操作。如果从不重新启动此服务,将会比直接使用 ASP.NET 更有可能得到解决方案,因为从理论上讲,拥有无法自动重新启动的服务进程可确保不会丢失长时间运行操作的状态。
但这样真的可以解决该问题吗?很可能不会。如果服务器需要负载平衡该怎么办?如果思路仅局限于单个进程将会使情况变得非常困难。更糟糕的是,如果需要重新启动服务器或进程崩溃该怎么办? 如果这样将会导致丢失已运行的所有操作。
事实上,当操作需要数天或数周才可完成时,您就需要一个独立于执行该操作进程的生命周期的解决方案。通常这是一个不争的事实,对于 ASP.NET Web 应用程序更是尤为重要。
管理工作流
Windows Workflow Foundation (WF) 可能并不是构建 Web 应用程序时想到的技术。但 WF 提供的多个主要功能让工作流解决方案有了不同寻常的意义。有了 WF,您就能通过从进程空间中彻底卸载空闲工作流,并在工作流繁忙时自动将它们重新加载到活动进程中,让长时间运行的操作具备进程独立性(请参见图 1)。利用 WF 可以克服 ASP.NET 工作进程不确定生命周期的影响,并为 Web 应用程序内的长时间运行操作做好准备。
图 1 跨进程实例的工作流保留操作
WF 组合了两个主要特性来实现此功能。首先,异步活动在等待外部事件时向工作流运行时发出工作流空闲的信号。接下来,持久性服务从该进程卸载空闲的工作流,然后将其保存到某个持久存储位置(如数据库)并在做好再次运行准备时重新加载工作流。
这样的进程独立性还有其他的优点。它提供了一种简单的负载平衡方式及持久性——在遇到进程或服务器故障时能实现容错。
同步和异步活动
活动是 WF 的原子元素。所有的工作流都是通过与复合设计模式相似的方式使用活动构建的。实际上工作流本身就是经过简单特殊处理的活动。这些活动分为同步或异步两类。同步活动自始至终都在执行其所有指令。
例如,在线商店得到订单后计算税金就是同步活动。我们来看一下如何执行此类活动。与大多数 WF 活动一样,大部分工作都是在替换 Execute 方法时发生的。执行该方法的步骤可能类似如下所示:
- 从上一个活动获取订单数据。这通常是通过数据绑定完成的,稍后您可以看到它的示例。
- 从数据库中查找与该订单关联的客户。
- 根据该客户的位置从数据库中查找税率。
- 使用税率和与订单关联的订单项目进行一些简单的数学运算。
- 将税金总额存储在与后续活动绑定的属性中以完成结账过程。
- 通过从 Execute 方法返回“完成”状态标记向工作流运行时通知该活动已完成。
注意:您没有等待状态。一直处于工作状态中。Execute 方法只需要运行这些步骤并快速地完成即可。这就是同步活动的本质:所有工作都在 Execute 方法中完成。
异步活动则不同。与其对应的同步活动不同,异步活动执行一段时间后,会等待外部促进因素。在等待时,活动变为空闲状态。事件发生后,活动继续操作并完成执行。
例如,录用过程中,经理审核职位申请就属于异步活动。考虑一下如果经理正在度假,一周内无法审核申请将会发生什么情况。在等待此响应时,中途阻止 Execute 方法是完全不可取的。等待某个人员来运行软件可能需要等候很长的时间。您需要在设计中考虑到这种情况。
“空闲”究竟指什么?
这个词的英语语义和体系结构语义有分歧。脱离 WF,按常规思考一下“空闲”的含义。
请考虑下列使用 Web 服务更改密码的类:
public class PasswordOperation : Operation {
Status ChangePassword(Guid userId, string pw) {
// Create a web service proxy:
UserService svc = new UserService();
// This can take up to 20 sec for
// the web server to respond:
bool result = svc.ChangePassword( userId, pw );
Logger.AccountAction( "User {0} changed pw ({1}).",
userId, result);
return Status.Completed;
}
}
ChangePassword 方法是否空闲?如果是,那么位置在哪里?
此方法的线程在等待来自 UserService 的 HTTP 响应时受阻。如此,从概念上讲,该线程处于空闲状态并在等候服务器响应。但实际上该线程在等候服务时可进行其他工作吗?不可以,因为它目前仍在使用中。因此,从 WF 的角度来看,此“工作流”永远不会空闲。
为什么永远不会空闲呢?假设您有一些可有效运行诸如 ChangePassword 操作的大型调度程序类。此处的“有效”是指并行运行多个操作,使用完全并行所需的最小数量的线程等。事实证明,实现此有效性的关键在于了解何时执行操作以及何时操作处于空闲状态。因为在操作变为空闲时,调度程序可使用运行该操作的线程来执行其他工作,直至该操作准备再次运行。
令人遗憾的是,ChangePassword 方法对于调度程序是完全不透明的。因为尽管会有某个时段其处于有效空闲状态,但调度程序从外部观察时该方法仍是阻止工作的一个单元。在空闲时段,调度程序无法将该工作单元分割开并重新使用该线程。
同步任务异步化
您可以通过将该操作分割为两部分来将这个所需的调度透明性添加到操作中:一部分在操作空闲之前执行操作,另一部分在空闲状态后执行代码。
对于之前所示的假定示例,您可以使用 Web 服务代理自身提供的异步功能。请务必记得这只是一种简化的情形,事实上 WF 的工作方式稍有不同,稍后即可明白这一点。
在图 2 中,我创建了 password-changing 方法的改进版本,称为ChangePasswordImproved。我像以前一样创建了 Web 服务代理。然后该方法注册一个回调方法以在服务器响应时获得通知。接下来,我将异步执行该服务调用并通过返回 Status.Executing 告诉调度程序该操作处于空闲状态,但尚未完成。这个步骤很重要,因为它能让调度程序在代码空闲时完成其他工作。最后,事件完成时,我会调用调度程序通知操作已完成,可以继续执行后续步骤。
图 2 简单的 Password-Changing 服务调用
public class PasswordOperation : Operation {
Status ChangePasswordImproved(Guid userId, string pw) {
// Create a web service proxy:
UserService svc = new UserService();
svc.ChangePasswordComplete += svc_ChangeComplete;
svc.ChangePasswordAsync( userId, pw );
return Status.Executing;
}
void svc_ChangeComplete(object sender, PasswordArgs e) {
Logger.AccountAction( "User {0} changed pw ({1}).",
e.UserID, e.Result );
Scheduler.SignalCompleted( this );
}
}
工作流和活动
现在,我将应用操作空闲的概念在 WF 中构建活动。它与您之前看到的内容非常相似,但我现在必须在 WF 模型内工作。
WF 带有很多内置活动。但如果您是第一次着手利用 WF 构建实际系统,您很快就会想为自己构建可重用的自定义活动。这个很简单。您只需要定义一个从常用 Activity 类派生的类即可。下面是一个基本示例:
class MyActivity : Activity {
override ActivityExecutionStatus
Execute(ActivityExecutionContext ctx) {
// Do work here.
return ActivityExecutionStatus.Closed;
}
}
要使活动执行一些有用的操作,必须覆盖 Execute 方法。如果构建的是短期存在的同步活动,可直接在此方法内实现活动的操作,然后返回状态“Closed”(关闭)。
在实际的活动中很可能需要考虑几个大问题。您的活动如何与工作流内的其他活动以及托管工作流的大型应用程序通信?它如何访问服务(如数据库系统、UI 交互等)?在构建同步活动时,这些问题相对而言比较简单。
但在构建异步活动时问题就要复杂多了。幸运的是,所使用的模式在大多数异步活动中都是重复的。事实上,您可以轻松地在基类中捕获此模式,稍后我将对此加以说明。
构建大多数异步活动时均需要下列基本步骤:
- 创建一个派生自 Activity 的类。
- 覆盖 Execute 方法。
- 创建一个工作流队列,用于接收所等待的异步事件已完成的通知。
- 订阅该队列的 QueueItemAvailable 事件。
- 启动长时间运行操作(例如发送电子邮件请经理审核工作岗位的申请)。
- 等待外部事件发生。这样可以有效地发出该活动已变为空闲的信号。可通过返回 ExecutionActivityStatus.Executing 向工作流运行时指出此情况。
- 事件发生时,处理 QueueItemAvailable 事件的方法会从队列中删除该项,然后将其转换为期望的数据类型并处理结果。
- 通常这样会终止活动的操作。然后工作流运行时通过返回 ActivityExecutionContext.CloseActivity 发出信号。
持久性
在本文的开头,我就提到过通过工作流实现进程独立需要两个基本条件:异步活动和持久性服务。您刚才看到的是异步活动的说明。现在来深入探讨持久性的深层技术:工作流服务。
工作流服务是 WF 的主要扩展点。WF 运行时是一个在应用程序中实例化以托管所有运行工作流的类。此类有两个相悖的设计目标,它们可通过工作流服务的概念同时实现。此工作流运行时的第一个目标是成为可在多处使用的轻型对象。此工作流运行时的第二个目标是在工作流运行时向其提供多个强大的功能。例如,自动保持空闲工作流、跟踪工作流进度及支持其他自定义功能等。
默认情况下,工作流运行时保持轻型状态,因为这些功能中只有一对是内置功能。更多的重型服务(如持久性和跟踪)是通过服务模型选择安装的。事实上,服务的定义可以是您希望向工作流提供的任何全局功能。您只需调用 WorkflowRuntime 类的 AddService 方法即可在运行时中安装这些服务:
void AddService(object service)
由于 AddService 采用了 System.Object 引用,您可以添加工作流所需的任何内容。
我将使用两项服务。首先,使用 WorkflowQueuingService 访问构建异步活动的基础工作流队列。默认情况下,此服务已安装且无法自定义。另一个服务为 SqlWorkflowPersistenceService。当然,此服务会提供持久性功能且默认未安装。幸运的是,WF 中已包含这项服务。您只需要将其添加到运行时即可。
从类似 SqlWorkflowPersistenceService 这样的名称推断,您可以确信必定有某处需要数据库。您可以为此创建空数据库,或者向现有数据库添加新表。我个人倾向使用专门的数据库,而不是将工作流持久性数据与其他数据混合在一起。因此我在 SQL Server 中创建了一个名为 WF_Persist 的空数据库。我通过运行两个脚本来创建所需的数据库架构和存储过程。它们是随同 Microsoft .NET Framework 一起安装的,默认文件夹是:
C:\Windows\Microsoft.NET\Framework\v3.0\Windows Workflow Foundation\SQL\EN\
您将希望首先运行 SqlPersistenceService_Schema.sql 脚本,然后运行 SqlPersistenceService_Logic.sql 脚本。现在,我可以通过将连接字符串传递给持久性服务来将此数据库用于持久性:
SqlWorkflowPersistenceService sqlSvc =
new SqlWorkflowPersistenceService(
@"server=.;database=WF_Persist;trusted_connection=true",
true, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
wfRuntime.AddService(sqlSvc);
如果想先卸载空闲工作流并将其存储在数据库中,然后在需要时还原它们,这个简单的 AddService 方法调用就能完成全部工作。WF 运行时还负责其他所有事情。
使之变为现实
既然已有了足够的技术基础,您就可以将它们组合起来构建一个支持长时间运行操作的 ASP.NET Web 站点。在此示例中您将看到三个主要元素:异步活动的结构、将工作流运行时集成到 Web 应用程序中以及在 Web 页面中与工作流通信。
我将设计一个名为 Trey Research 的假想 .NET 咨询公司。他们希望为顾问自动化招聘和录用过程。因此我将构建一个 ASP.NET Web 站点以支持此录用过程。我尽量对操作进行简化,但还是要有几个步骤:
- 求职者访问 Trey Research Web 站点并表示对某个工作岗位感兴趣。
- 向经理发送电子邮件通知有新的申请人。
- 该经理将审核申请并批准求职者得到某个特定的职位。
- 向该求职者发送电子邮件告知所提议职位的相关信息。
- 求职者会访问该 Web 站点接受或拒绝该职位。
尽管此过程很简单,但仍有几个步骤需要申请等候某个人进行回复并填写更多信息。这些空闲点将会花费很长的时间。因而这种情况正是长时间运行进程大展身手的好时机。
此 Web 应用程序包括在本文的源代码中。但要看到完整的效果还需要创建持久性数据库并配置示例加以使用。我创建了一个开关,用于控制持久性服务的打开或关闭,默认将其设置为关闭。要打开,可将 web.config 的 AppSettings 中的 usePersistDB 设置为 true。要查看其运行效果,可访问我的网站上的异步工作查看器 运行。
图 3 录用工作流
我将从设计完全独立于 ASP.NET 的工作流开始着手。为构建该工作流,我将创建四个自定义活动。第一个活动是发送电子邮件,这是一个简单的同步活动。其他三个活动分别表示之前所示的步骤 1、3 和 5,这是三个异步活动。这些活动是长时间运行操作成功的关键。我将它们分别称作 GatherEmployeeInfoActivity、AssignJobActivity 和 ConfirmJobActivity。然后将这些活动合并到工作流框架中,如图 3 所示。
发送电子邮件活动比较简单,因此无需在本文中深入讨论该活动的细节。这是一个同步活动,与之前所示的 MyActivity 类很相似。有关详细信息请查看所代码下载。
接下来要创建三个异步活动。如果能将构建异步活动的八步过程压缩为一个通用基类就可以省去一大部分工作了。为此,我将定义一个名为 AsyncActivity 的类(请参见图 4)。请注意,此列表并不包括几种内部辅助方法或实际代码中所提供的错误处理。为了简洁起见,这些细节均已省去。
图 4 AsyncActivity
public abstract class AsyncActivity : Activity {
private string queueName;
protected AsyncActivity(string queueName) {
this.queueName = queueName;
}
protected WorkflowQueue GetQueue(
ActivityExecutionContext ctx) {
var svc = ctx.GetService<WorkflowQueuingService>();
if (!svc.Exists(queueName))
return svc.CreateWorkflowQueue(queueName, false);
return svc.GetWorkflowQueue(queueName);
}
protected void SubscribeToItemAvailable(
ActivityExecutionContext ctx) {
GetQueue(ctx).QueueItemAvailable += queueItemAvailable;
}
private void queueItemAvailable(
object sender, QueueEventArgs e) {
ActivityExecutionContext ctx =
(ActivityExecutionContext)sender;
try { OnQueueItemAvailable(ctx); }
finally { ctx.CloseActivity(); }
}
protected abstract void OnQueueItemAvailable(
ActivityExecutionContext ctx);
}
在此基类中,您会看到我总结的构建异步活动的几个单调重复的部分。让我们从此类开始逐个研究一翻。由构造函数开始,我传入一个字符串作为队列名称。工作流队列是托管应用程序(Web 页面)的输入点,用于在保持松散耦合的同时将数据传入到活动中。这些队列是按名称和工作流实例引用的,因此每个异步活动都需要有其自己独有的队列名称。
然后,我定义了 GetQueue 方法。如您所见,访问和创建工作流队列很容易,但有些单调。我创建的这一方法可作为辅助方法在此类和派生类中使用。
之后定义了一个名为 SubscribeToItemAvailable 的方法。此方法封装了某个项目到达工作流队列时所触发事件的订阅细节。这通常表示长时间等待结束,工作流已空闲。因此,所用示例如下所示:
- 开始长时间运行操作并调用 SubscribeToItemAvailable。
- 通知工作流运行时该活动处于空闲状态。
- 工作流实例通过持久性服务序列化为数据库。
- 操作结束时,将某个项目发送到工作流队列中。
- 这将触发工作流实例从数据库中还原。
- 抽象模板方法 OnQueueItemAvailable 由基类 AsyncActivity 执行。
- 该活动完成其操作。
要查看此 AsyncActivity 类的运转状况,需实现 AssignJobActivity 类。其他两个异步活动很相似,均已包括在代码下载中。
在图 5 中,您可以了解到 AssignJobActivity 是如何使用 AsyncActivity 基类提供的模板的。我覆盖了 Execute 方法,以完成长时间运行活动的前期准备(尽管此示例中实际并没有前期准备)。然后订阅事件以了解何时有更多数据可用。
图 5 AssignJobActivity
public partial class AssignJobActivity : AsyncActivity {
public const string QUEUE NAME = "AssignJobQueue";
public AssignJobActivity()
: base(QUEUE_NAME)
{
InitializeComponent();
}
protected override ActivityExecutionStatus Execute(
ActivityExecutionContext ctx) {
// Runs before idle period:
SubscribeToItemAvailable(ctx);
return ActivityExecutionStatus.Executing;
}
protected override void OnQueueItemAvailable(
ActivityExecutionContext ctx) {
// Runs after idle period:
Job job = (Job)GetQueue(ctx).Dequeue();
// Assign job to employee, save in DB.
Employee employee = Database.FindEmployee(this.WorkflowInstanceId);
employee.Job = job.JobTitle;
employee.Salary = job.Salary;
}
}
此处有一个隐式约定,在从经理那里收集到新的 Job 对象后,托管应用程序(Web 页面)会将其发送到该活动的队列中。此操作将告诉活动可以继续。这会更新数据库中的员工。工作流中的下一个活动会向潜在员工发送电子邮件,通知向他推荐此职位。
与 ASP.NET 集成
这就是其在工作流内部的工作方式。但是如何启动工作流呢?实际中的 Web 页面如何收集经理提供的工作邀请呢?如何将 Job 传送给活动呢?
我们首先要明确的是:如何启动工作流。在 Web 站点的登录页面上有一个“Apply Now”(立即申请)链接。求职者单击此链接时,将通过用户界面并行启动工作流和导航:
protected void LinkButtonJoin_Click(
object sender, EventArgs e) {
WorkflowInstance wfInst =
Global.WorkflowRuntime.CreateWorkflow(typeof(MainWorkflow));
wfInst.Start();
Response.Redirect(
"GatherEmployeeData.aspx?id=" + wfInst.InstanceId);
}
我只需在工作流运行时调用 CreateWorkflow 并启动工作流实例即可。之后,我将实例 ID 作为查询参数传递给所有后续 Web 页面,以此跟踪该工作流实例。
如何将 Web 页面中的数据发送回工作流呢?我们来看分配工作的页面(如图 6 所示),经理为某个求职者选择了一份工作。
图 6 分配工作
public class AssignJobPage : System.Web.UI.Page {
/* Some details omitted */
void ButtonSubmit_Click(object sender, EventArgs e) {
Guid id = QueryStringData.GetWorkflowId();
WorkflowInstance wfInst = Global.WorkflowRuntime.GetWorkflow(id);
Job job = new Job();
job.JobTitle = DropDownListJob.SelectedValue;
job.Salary = Convert.ToDouble(TextBoxSalary.Text);
wfInst.EnqueueItem(AssignJobActivity.QUEUE_NAME, job, null, null);
buttonSubmit.Enabled = false;
LabelMessage.Text = "Email sent to new recruit.";
}
}
分配工作的 Web 页面大体上是一个简单的输入表格。它包括一个可用工作的下拉列表和一个输入提议薪酬的文本框。还显示当前的求职者,尽管列表中已忽略该代码。当经理为求职者分配职位和薪酬时,可单击提交按钮然后运行图 6 中的代码。
此页面将工作流实例 ID 用作查询字符串参数,查找关联的工作流实例。然后使用表格中的值创建并初始化 Job 对象。最后,通过将工作排入该活动的队列中将此信息发送回活动。这是重新加载空闲工作流并允许其继续执行的关键步骤。AssignJobActivity 将此工作与之前收集的员工建立关联,然后将它们保存到数据库中。
后两个代码列出项强调了基本工作流队列对异步活动和工作流与外部主机成功通信的作用。还应注意这里所使用的工作流对于页面流没有任何影响。尽管也可以使用 WF 控制页面流,但这并不是本文的重点。
在图 6 中,您会看到通过全局应用程序类访问工作流运行时,如下所示:
WorkflowInstance wfInst =
Global.WorkflowRuntime.GetWorkflow(id);
这样就完成了将 Windows Workflow 集成到 Web 应用程序中的过程:所有工作流都在工作流运行时内执行。尽管 AppDomain 中的工作流运行时的数量不受限制,但通常最好使用单个工作流运行时。有鉴于此,再加上 WF 运行时对象具有线程安全性,我将其变为全局应用程序类的某个公共静态属性。此外,我在应用程序启动事件中启动该工作流运行时,在应用程序终止事件中停止该工作流运行时。图 7 是简化的全局应用程序类。
图 7 启动工作流运行时
public class Global : HttpApplication {
public static WorkflowRuntime WorkflowRuntime { get; set; }
protected void Application_Start(object sender, EventArgs e) {
WorkflowRuntime = new WorkflowRuntime();
InstallPersistenceService();
WorkflowRuntime.StartRuntime();
// ...
}
protected void Application_End(object sender, EventArgs e) {
WorkflowRuntime.StopRuntime();
WorkflowRuntime.Dispose();
}
void InstallPersistenceService() {
// Code from listing 4.
}
}
在应用程序启动事件中,我创建了运行时,安装了持久性服务并启动了该运行时。在应用程序终止事件中停止了该运行时。这一步非常重要。如果这些工作流卸载前仍存在运行时工作流,将会发生阻断。停止该运行时后,我调用了 Dispose。先调用 StopRuntime 再调用 Dispose 可能看起来很多余,但实际情况并非如此。您需要依照该次序来调用这两个方法。
考虑事项
现在我请大家思考一些我未直接谈及的内容,这些内容以问答形式提出。我为什么不使用 ManualWorkflowSchedulerService?通常人们在谈到 WF 与 ASP.NET 集成时,会强调应将使用线程池的工作流的默认调度程序替换成一项名为 ManualWorkflowSchedulerService 的服务。这是由于该调度程序可能并不需要或者确实不适用于长时间运行。当您期望在某个给定的请求内完成单个工作流时,手动调度程序是个不错的选择。但工作流跨进程生命周期执行就没多大意思了,跨请求更是如此。
是否有方法可以跟踪某个给定工作流实例的当前进程呢?有的,WF 中已构建了一项完整的跟踪服务,其使用方式与 SQL 持久性服务相似。请参阅 2007 年 3 月刊“基础内容”专栏中 Matt Milner 撰写的“Windows Workflow Foundation 中的跟踪服务”。
综述
我可以用几个步骤归纳本文中所讨论的技术。首先我讲述了 ASP.NET 工作进程和进程模型通常不适用于长时间运行操作的原因。为了摆脱这种局限,我组合了 WF 中的两个功能实现进程独立性:异步活动和工作流持久性。
由于构建异步活动稍微有些难度,我将很多细节封装到了本文所介绍的 AsyncActivity 基类中。然后将长时间运行操作表述为用异步活动构建的顺序工作流,我可以将其封装到 Web 应用程序中,从而轻松实现进程独立。
最后,我介绍了将工作流集成到 ASP.NET 中的两项基本内容:通过工作流队列与活动通信以及在全局应用程序类中托管运行时。
您已看到,将 WF 与 ASP.NET 集成在一起可支持长时间运行操作,它是一个功能更为强大的工具,可在 .NET Framework 之上构建解决方案。
Michael Kennedy 是 DevelopMentor 的一名讲师,专门负责核心 .NET 技术以及敏捷和 TDD 开发方法。您可以通过 Michael 的网站和博客与其联系,地址为 michaelckennedy.net。