65.9K
CodeProject 正在变化。 阅读更多。
Home

Zombie Explorer: 一个从头到尾的 N 层应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

5.00/5 (123投票s)

2012年10月10日

CPOL

30分钟阅读

viewsIcon

416964

downloadIcon

3381

一个完整的端到端示例,从数据库到使用MVVM/PRISM/NHibernate/Repository/IOC的WPF客户端。

我只想代码,给我代码,好吧,冷静点,就在这里

目录

引言

这篇文章是我一直想写很久了,与其说是为了别人的利益,不如说是为了我自己的利益。在我们开始讨论文章内容之前,让我先概述一下我为什么想写这篇文章。

我从事WCF/WPF开发已经有一段时间了(我知道有些人认为这些是旧技术了,但我可以向你保证,它们都还活着,至少在我所处的环境中是这样),并且我见过各种不同的方法来开发使用这些技术的n层应用程序。

我见过好的和坏的,其中一些坏的包括

  • 服务上令人难以置信的方法数量,这会很快变得难以维护
  • 一切都是自己的服务,你有大约30个WCF服务互相调用;简直是噩梦
  • 糟糕的关注点分离,依赖关系图一片混乱,你的UI不知不觉地知道太多了,因为它被迫引用它本不该知道的东西
  • 使用Reference.cs(稍后会详细介绍)
  • 缺乏对DD/IOC/Mocking或人们现在认为理所当然的任何酷炫东西的支持/考虑

我想尝试做一个演示应用程序,能按照我脑海中的思路来做。我只是想能够从数据库到WPF客户端,完成一个完整的应用程序,只处理我自己的想法(和头痛/噩梦)。

我坦白我使用了一些我见过的方法,比如发出一个Request并收到一个Response。我其实喜欢这个模式,因为它使WCF服务契约非常简单,因为它基本上是Response ExecuteRequest(Request req),这意味着无论你添加多少东西,你的服务契约都会保持最新,前提是你保持WCFKnownType是最新的。

对于附带的演示代码,我想确保我涵盖了以下方面

  • 提供良好的关注点分离,也就是说UI不应该关心服务器端的业务逻辑,为什么它应该关心?
  • 能够轻松地替换/测试我们应用程序的某些区域(IOC允许这样做)
  • 数据库访问应该尽可能精简(NHibernate和Repository模式的使用促进了这一点)
  • 应该能够测试整个系统的一小部分

我将项目命名为“WcfExemplar”,我希望人们不要觉得我傲慢,它绝不是这个意思。它更多是为了我自己,因为我这样做是为了向自己证明一些东西,所以它对我来说是一个“Exemplar”,如果你愿意的话。希望你能原谅我这次放纵。

那么演示应用程序做什么?

这是一个WPF(Prism/MVVM为基础)客户端,它与一个控制台托管的WCF服务应用程序通信。该应用程序允许用户搜索数据库中存储的僵尸事件。用户也可以添加新的僵尸事件。所以它实际上并不复杂,只是基本的CRUD操作。

我认为主要的关键点如下

  • WPF前端(我不会过多纠缠,因为它只是一个展示服务器端概念的载体),它允许用户
    • 搜索项目(我存储僵尸数据,其中包含每个僵尸事件的标题/描述和地理位置数据)
    • 添加新项目
    • 在地图上查看项目(我选择使用Bing Maps)
    • 使用Rx显示全球僵尸事件的RSS Feed(算是一个奖励吧)
  • 它使用一个共享DLL,只有相关内容在客户端和服务器之间共享,这确保了依赖关系图的合理性。所以下面所示的内容通常会被共享
    • 业务对象
    • 服务契约
    • 故障契约
  • 没有自动生成的Reference.xx或客户端代理,我们使用一个共享DLL并手工编写自己的客户端代理
  • 它使用Fluent NHibernate进行持久化,其中ISession提供了工作单元(UOW)
  • 它使用IOC,以便任何与外部部分(即数据库)通信的组件都可以轻松替换。这是从WCF服务开始的
  • 它使用业务逻辑在任何尝试持久化对象之前对对象进行任何操作。
  • 它尽量减少与数据库的连接,并且只在最短的时间内与数据库进行交互。

请尽量记住我正在使用Request/Response机制来编写,所以即使你不喜欢Request/Response/Task方法的整体结构,我希望其中会有一些东西能让你保持兴趣。

先决条件

要运行附带的代码,您需要具备以下组件

  1. 一台性能不错的PC(能够运行WPF和SQL Server的PC)
  2. 您自己的SQL Server安装
  3. 此演示使用Bing Maps,因此您需要获取Bing Maps开发者API密钥(或者忍受演示中显示的水印提醒消息,如果您没有API密钥的话,老实说这也不是什么大事。您可以从以下链接获取Bing Maps API密钥:http://msdn.microsoft.com/en-us/library/ff428642.aspx
  4. 需要稳定的互联网连接,因为UI假定它是联网的,以便显示地图和读取RSS Feed

入门

本迷你分步指南将告诉您需要执行哪些操作才能运行演示应用程序

  1. 在SQL Server中创建一个名为“WcfExemplar”的新数据库
  2. 运行以下SQL脚本
    • 02 创建ZombieIncidents 表.sql
    • 03 创建 GeoLocations 表.sql
    • 04 创建一些虚拟数据.sql
  3. 更改“Hosting\ConsoleHost\WcfExamplar.Host”项目中的“ZombieDB”SQL连接字符串,使其指向
    您自己的SQL Server安装
  4. 注册一个Bing Maps API密钥(如果您想去除提醒消息),并在以下文件的XAML中输入您的详细信息
    • WcfExemplar.WpfClient.Common\Controls\SlimLineZombieIncidentMapView.xaml,找到Bing Map控件并填写“CredentialsProvider="INSERT_YOUR_BING_MAPS_KEY"”这一行
    • WcfExemplar.WpfClient.MapModule\ZombieIncidentMapView.xaml,找到Bing Map控件并填写“CredentialsProvider="INSERT_YOUR_BING_MAPS_KEY"”这一行
  5. 通过右键单击“Hosting\ConsoleHost\WcfExamplar.Host”项目,然后选择Debug->Start New Instance来运行WCF控制台宿主
  6. 通过右键单击“UI\PRISM\WcfExemplar.WpfClient”项目,然后选择Debug->Start New Instance来运行WPF客户端

服务器端:WCF

本节将讨论WCF服务层。简而言之,WCF服务的作用是提供一个单一方法接口服务契约。客户端将使用Request调用WCF服务,这些Request在共享DLL中可用。

Request立即将其属性提供给TaskTask进而调用适当的业务逻辑。如果业务逻辑检查某个操作是否合理,并进行任何特定的验证,则允许与持久化层通信。我在这里使用了Fluent NHibernate UOW和Repository模式。

分层之必要 - 何必费心

现在您应该明白,有一个接受Request的服务,它允许创建Task,而Task是真正做事的人。

本文代码有类似之处

Request在共享DLL中公开。Task的查找是在实际WCF服务中通过一点反射完成的,这只在WCF服务内进行,所以WCF客户端对此一无所知,也不知道Task的任何现有依赖项,如x/y和z。所以你获取应该共享的东西。

我们之所以要关心这一点,是因为“关注点分离”。WCF客户端不应该关心其他任何事情,除了获取/发送业务对象,并通过通用服务契约使用Request/Response对象。当然,也会有共享的FaultContract对象,但它们也是WCF服务和WCF客户端之间的共享DLL的一部分。

在我看来,这是解决拼图的重要一块,它提供了良好的关注点分离,并允许重用关键的共享代码。

WCF服务

如前所述,WCF非常简单,这是共享的服务契约接口,请看它实际上只有一个方法,该方法接受一个Request并返回一个Response

[ServiceContract]
public interface IGateway
{
    [OperationContract]
    [FaultContract(typeof(GenericFault))]
    [FaultContract(typeof(BusinessLogicFault))]
    Response ExecuteRequest(Request request);
}

该服务契约通过共享DLL与客户端共享。完整的服务实现如下所示

[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerCall)]
public class Gateway : IGateway
{
    public Response ExecuteRequest(Request request)
    {
        WcfExemplar.Common.Logging.LogManager.Instance.Logger("Gateway")
            .InfoFormat("Executing request : {0}", request.GetType().Name);

        object requestHandler = null;
        try
        {
            Type requestType = request.GetType();
            Type responseType = GetResponseType(requestType);
            Type requestHandlerType = typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType);
            requestHandler = container.Resolve(requestHandlerType);
            return (Response)requestHandlerType.GetMethod("Handle").Invoke(requestHandler, new[] { request });
        }
        catch (BusinessLogicException bex)
        {
            BusinessLogicFault bf = new BusinessLogicFault();
            bf.Operation = requestHandler.GetType().FullName;
            bf.Message = bex.Message;
            throw new FaultException<BusinessLogicFault>(bf);
        }
        catch (Exception ex)
        {
            GenericFault gf = new GenericFault();
            gf.Operation = requestHandler.GetType().FullName;
            gf.Message = ex.Message;
            throw new FaultException<GenericFault>(gf);
        }
    }

    private static Type GetResponseType(Type requestType)
    {
        return GetRequestInterface(requestType).GetGenericArguments()[0];
    }

    private static Type GetRequestInterface(Type requestType)
    {
        return (
            from @interface in requestType.GetInterfaces()
            where @interface.IsGenericType
            where typeof(IRequest<>).IsAssignableFrom(
                @interface.GetGenericTypeDefinition())
            select @interface)
            .Single();
    }
}

这里有几点需要注意,如下

  • 我们可以看到我们正在使用FaultException将故障发送到WCF客户端(我们允许的那些已在共享服务契约上标注)
  • 我们使用一点反射来获取IRequestHandler<,>的IOC容器注册实例,这碰巧是能够接受当前Request实例的Task(这就是我们提供良好关注点分离,并只共享我们应该共享的东西的方式)
  • 我们使用Castle Windsor IOC容器(稍后会详细介绍)
  • 我们使用InstanceModeContext.PerCall,因为这似乎是与NHibernate及其ISession UOW对象一起工作的最佳实践。ISession应该是短暂而精炼的,不应该长时间存在。

此服务托管在简单的控制台应用程序宿主中(如果您有兴趣,请参阅WcfExamplar.Host项目)

IOC

控制反转”,在我看来,是企业级应用程序开发绝对的福音。

它基本上促进了更松耦合的架构,并且鼓励使用接口(如果你问我,就是策略模式),这反过来又为测试创造了更好的途径。考虑到这两点,我的意图是从WCF服务开始,一直向下提供IOC支持。以便它可以在服务器端WCF代码的每个级别上使用。

那么我使用哪个IOC库呢?嗯,我有几个最喜欢的,但Castle Windsor是其中之一,所以我使用了它。

WCF服务本身做的工作很少,除了找到一个Task并运行它。所以是Task开始做所有实际工作的地方,所以WCF服务没有IOC注册的依赖项也就不足为奇了。然而,它会在每次调用时设置Castle Windsor IOC容器,是的,我说了PerCall实例上下文。

之所以使用PerCall,是因为我正在使用NHibernate。NHibernate使用的ISession UnitOfWork用于短暂的使用,不应该长时间保持活动状态。它最常用于网站,这些网站都采用每个请求的安排,在我看来,这与PerCall WCF实例上下文服务最相似。

这是如何做的

[ServiceBehavior(InstanceContextMode = InstanceContextMode.PerCall)]
public class Gateway : IGateway
{
    
}

Castle Windsor允许我们通过提供一个或多个容器安装程序文件来配置IOC容器。我正在使用一个单一的Castle Windsor容器安装程序,它看起来像这样

public class StandardInstaller : IWindsorInstaller
{
    public void Install(IWindsorContainer container, IConfigurationStore store)
    {
        string dbConnectionString = ConfigurationManager.ConnectionStrings["ZombieDB"].ConnectionString;

        container.AddFacility<WcfFacility>()
        .Register(
            Component.For<NHibernateHelper>().DependsOn(new
                    {
                        connectionString = dbConnectionString
                    }).LifeStyle.Singleton,
            Component.For<Func<ISessionFactory>>().Instance(() => 
               container.Resolve<NHibernateHelper>().SessionFactory).LifeStyle.Singleton,
            Component.For<Func<ISession>>().Instance(() => 
              ((INHibSessionProvider)container.Resolve<IUnitOfWork>()).Session).LifeStyle.PerWcfOperation(),
            Component.For<IUnitOfWork>().ImplementedBy<NHibernateUnitOfWork>().LifeStyle.PerWcfOperation(),
            Component.For<IRepository<ZombieIncident>>().ImplementedBy<NHibernateRepository<ZombieIncident>>().LifeStyle.PerWcfOperation(),
            Component.For<IRepository<GeoLocation>>().ImplementedBy<NHibernateRepository<GeoLocation>>().LifeStyle.PerWcfOperation(),
            Component.For<IZombieIncidentDomainLogic>().ImplementedBy<ZombieIncidentDomainLogic>().LifeStyle.Transient,
            Component.For<IGateway>().ImplementedBy<Gateway>().LifeStyle.PerWcfOperation()
        );
    }
}

如上所示,它使用了几个很棒的Castle特性,比如匿名对象和Func<T>委托的使用,它们被用作轻量级工厂来为Fluent NHibernate相关的类提供值。稍后我们将看到更多。

另一点需要注意的是,我正在使用Castle WCF组件,并且还使用了Castle Service Host(其中最重要的部分如下所示)

IOCManager.Container.Install(new IWindsorInstaller[] { new StandardInstaller(), new TaskInstaller() });
simpleServiceHost = new DefaultServiceHostFactory(IOCManager.Container.Kernel)
    .CreateServiceHost(typeof(IGateway).AssemblyQualifiedName
    , new Uri[0]);
StartServiceHost(simpleServiceHost);

我们还使用了另一个安装程序,它将在Castle Windsor IOC容器中注册所有Task。如下所示

public class TaskInstaller : IWindsorInstaller
{
    public void Install(IWindsorContainer container, IConfigurationStore store)
    {
        container.Register(AllTypes
            .FromAssemblyContaining(typeof(SaveZombieIncidentTask))
            .BasedOn(typeof(IRequestHandler<,>))
            .Unless(t => t.IsGenericTypeDefinition)
            .WithService.Select((_, baseTypes) =>
            {
                return
                    from t in baseTypes
                    where t.IsGenericType
                    let td = t.GetGenericTypeDefinition()
                    where td == typeof(IRequestHandler<,>)
                    select t;
            }).LifestyleTransient());
        }
}

这个安装程序基本上会检查WcfExamplar.Server.Tasks.Dll中的所有类型,并查找实现IRequestHandler<,>接口的任何类型。这个接口应该由Task来实现。因此,当找到相关的Task时,我们只需调用它的Handle方法,并将当前Request作为方法参数传递。

请求

Request是WPF客户端(或其他WCF服务客户端)用来与WCF服务通信的内容。因此,Request对象是共享契约的一部分,可以在WPF客户端和WCF服务之间的共享DLL中找到。任何特定的Request只不过是一堆值,它们捕获应该传递给Task的输入。

所以Request可以被认为是属性包,它们通过查找相关的Task并告诉Task,嘿,UI让你做这件事,去吧。这里重要的部分是,WPF客户端对Task一无所知,因为这不好,否则它将被迫引用NHibernate.dll之类的东西,这对UI来说合适吗?不,对我来说也不是。

下面是一个典型的Request

[DataContract]
public class SaveZombieIncidentRequest : Request, IRequest<SaveZombieIncidentResponse>
{
    [DataMember]
    public ZombieIncidentInfo ZombieIncidentInfo { get; set; }

    public SaveZombieIncidentRequest(ZombieIncidentInfo zombieIncidentInfo)
    {
        ZombieIncidentInfo = zombieIncidentInfo;
    }
}

使用Request只有两个考虑因素,即

  • 确保Request继承自IRequest<T>,其中T是预期的Response类型。WCF服务使用此元数据查找。
  • 确保我们为Request添加属性,以确保它知道如何序列化其子类,以下是演示应用程序的实现方式。
[DataContract]
[KnownType(typeof(SaveZombieIncidentRequest))]
[KnownType(typeof(ZombieIncidentsRequest))]
[KnownType(typeof(SearchZombieIncidentsRequest))]
public abstract class Request
{
}

对于Response及其子类也是如此。

请求到任务映射

由于Request用于映射到Task,因此我们可以使用一小段很酷的反射,它基本上只是尝试在IOC容器中查找实现IRequestHandler<TRequest,TResponse>的类。只有Task才能实现这个接口。因此,当找到相关的Task时,我们只需调用它的Handle方法,并将当前的Request作为方法参数提供。

public Response ExecuteRequest(Request request)
{
    object requestHandler = null;
    try
    {
        Type requestType = request.GetType();
        Type responseType = GetResponseType(requestType);
        Type requestHandlerType = typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType);
        requestHandler = IOCManager.Container.Resolve(requestHandlerType);
        return (Response)requestHandlerType.GetMethod("Handle").Invoke(requestHandler, new[] { request });
    }
    catch (BusinessLogicException bex)
    {
    ....
    ....
    ....
    }
    catch (Exception ex)
    {
    ....
    ....
    ....
    }
}

WCF服务中的一小段反射实现了Task/Request之间的良好关注点分离。Task(s)知道Request(s),但Request(s)不知道Task(s)。因此Request可以轻松地在共享DLL中共享。

这就是典型的Request/Task的样子,希望这能说得通。

[DataContract]
public class SaveZombieIncidentRequest : Request, IRequest<SaveZombieIncidentResponse>
{
    [DataMember]
    public ZombieIncidentInfo ZombieIncidentInfo { get; set; }

    public SaveZombieIncidentRequest(ZombieIncidentInfo zombieIncidentInfo)
    {
        ZombieIncidentInfo = zombieIncidentInfo;
    }
}

public class SaveZombieIncidentTask : IRequestHandler<SaveZombieIncidentRequest, SaveZombieIncidentResponse>
{
    private IZombieIncidentDomainLogic zombieIncidentLogic;
    private IRepository<dtos.ZombieIncident> zombieRepository;
    private IRepository<dtos.GeoLocation> geoLocationRepository;
    private IUnitOfWork unitOfWork;
    private ZombieIncidentInfo zombieIncidentInfo;

    public SaveZombieIncidentTask(IZombieIncidentDomainLogic zombieIncidentLogic,
        IRepository<dtos.ZombieIncident> zombieRepository, 
    IRepository<dtos.GeoLocation> geoLocationRepository,
        IUnitOfWork unitOfWork)
    {
        this.zombieIncidentLogic = zombieIncidentLogic;
        this.zombieRepository = zombieRepository;
        this.geoLocationRepository = geoLocationRepository;
        this.unitOfWork = unitOfWork;
    }

    public SaveZombieIncidentResponse Handle(SaveZombieIncidentRequest request)
    {
        try
        {
            //get data
            this.zombieIncidentInfo = request.ZombieIncidentInfo;
        ....
        ....
        ....
        ....
            return new SaveZombieIncidentResponse(false);
        }
        catch (BusinessLogicException bex)
        {
            throw;
        }
        catch (Exception ex)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Tasks")
        .ErrorFormat("{0}\r\n{1}", ex.Message, ex.StackTrace);
            throw;
        }
    }
}

任务

Tasks是小单元的工作,但它们绝不应该调用其他任务。它们实际上是为了接收一些Request参数,通过一些业务逻辑处理数据,并可能使用NHibernate命中持久化层(稍后详细介绍)。

所以,如果你愿意,Tasks只是为了方便运行业务逻辑和与持久化层通信。

这是一个典型的Task可能的样子

public class SaveZombieIncidentTask : IRequestHandler<SaveZombieIncidentRequest, SaveZombieIncidentResponse>
{
    private IZombieIncidentDomainLogic zombieIncidentLogic;
    private IRepository<dtos.ZombieIncident> zombieRepository;
    private IRepository<dtos.GeoLocation> geoLocationRepository;
    private IUnitOfWork unitOfWork;
    private ZombieIncidentInfo zombieIncidentInfo;

    public SaveZombieIncidentTask(IZombieIncidentDomainLogic zombieIncidentLogic,
        IRepository<dtos.ZombieIncident> zombieRepository, 
    IRepository<dtos.GeoLocation> geoLocationRepository,
        IUnitOfWork unitOfWork)
    {
        this.zombieIncidentLogic = zombieIncidentLogic;
        this.zombieRepository = zombieRepository;
        this.geoLocationRepository = geoLocationRepository;
        this.unitOfWork = unitOfWork;
    }

    public SaveZombieIncidentResponse Handle(SaveZombieIncidentRequest request)
    {
        try
        {
            //get data
            this.zombieIncidentInfo = request.ZombieIncidentInfo;

            bool result = zombieIncidentLogic.CanStoreZombieIncident(zombieIncidentInfo);
            if (result)
            {
                ZombieIncident zombieIncident = ZombieIncidentDTOMapper.ToDTO(zombieIncidentInfo);

                zombieRepository.InsertOnSubmit(zombieIncident);
                zombieRepository.SubmitChanges();

                geoLocationRepository.InsertOnSubmit(zombieIncident.GeoLocation);
                geoLocationRepository.SubmitChanges();

                unitOfWork.Commit();
                unitOfWork.Dispose();
                return new SaveZombieIncidentResponse(result);
            }
            return new SaveZombieIncidentResponse(false);
        }
        catch (BusinessLogicException bex)
        {
            throw;
        }
        catch (Exception ex)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Tasks")
        .ErrorFormat("{0}\r\n{1}", ex.Message, ex.StackTrace);
            throw;
        }
    }
}

这个简单的Task实际上演示了很多东西

  1. 它的所有依赖项都由IOC容器提供,这些依赖项在构造函数中注入
  2. 通过实现IRequestHandler<TRequest,TResponse>接口来处理Request,这允许Task在Handle()方法中接收Request,从中可以获取数据
  3. 它立即委托给一些业务逻辑,这些逻辑可以用于一个公共DLL,该DLL不仅在WCF服务之间共享,而且可能被任何需要使用共享对象的通用业务逻辑的代码使用。尽管我并没有在附带的演示代码中走那么远
  4. 如果业务逻辑表明可以保存此实体,我们将在NHibernate的ISession UOW对象中使用NHibernate的事务性方式来要求持久化层(NHibernate)进行保存。

这就是我理解的Task的工作方式,精简高效,并委托给其他单一职责的领域/服务。

业务对象

依我看,如果你把事情做好,你甚至可以共享你的业务对象。你知道像验证规则这样的东西,它们听起来像是可以共享的,对吧?

如果你允许业务对象与Requests/WCF契约/Fault契约/业务逻辑一起共享,你就可以做各种疯狂的事情,比如包含对企业中的任何人都有用的方法。

这是演示应用程序中的一个典型业务对象

[DataContract]
public class ZombieIncidentInfo
{

    [DataMember]
    public int Id { get; set; }

    [DataMember]
    public string Heading { get; set; }

    [DataMember]
    public string Text { get; set; }

    [DataMember]
    public GeoLocationInfo GeoLocation { get; set; }


    public ZombieIncidentInfo(string heading, string text, GeoLocationInfo geoLocation)
    {
        this.Heading = heading;
        this.Text = text;
        this.GeoLocation = geoLocation;
    }

    public ZombieIncidentInfo(int id, string heading, string text, GeoLocationInfo geoLocation)
    {
        this.Id = id;
        this.Heading = heading;
        this.Text = text;
        this.GeoLocation = geoLocation;
    }


    public bool IsValid()
    {
        return !string.IsNullOrEmpty(Heading) && Heading.Length <= 50
                && !string.IsNullOrEmpty(Text) && Text.Length <= 300;
    }
}

有些人看到这个可能会说,嘿,我使用WinForms/WPF/WinRT/Silverlight,为什么没有INotifyPropertyChanged。对我来说,答案是我会为此创建一个UI特定的抽象(ViewModel)。

虽然非常非常简单,但我相信你们都会同意IsValid()方法似乎很有用,而且很可能比企业中的一个部分更感兴趣,所以就共享它吧。

业务逻辑

如前所述,我认为如果你把这些东西做好,甚至有可能在企业中的许多部分之间共享业务逻辑。尽管这显然只是一个演示应用程序,但下面展示的代码足够通用,可以用于WPF/WinForms UI、Web UI或另一个服务,几乎任何地方。在我看来,这关乎重用。

public class ZombieIncidentDomainLogic : IZombieIncidentDomainLogic
{

    public bool CanStoreZombieIncident(ZombieIncidentInfo zombieIncident)
    {
        bool incidentValid = true;
        bool geoLocationValid = true;


        if (zombieIncident == null)
        {
            string error = ("ZombieIncidentDomainLogic.CanStoreZombieIncident : zombieIncident can not be null");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
            throw new BusinessLogicException("Zombie Incident data provided is empty");
        }

        if (zombieIncident.GeoLocation == null)
        {
            string error = ("ZombieIncidentDomainLogic.CanStoreZombieIncident : zombieIncident.GeoLocation can not be null");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
            throw new BusinessLogicException("GeoLocation data provided is empty");
        }

        if (!zombieIncident.IsValid())
        {
            incidentValid = false;
            string error = ("ZombieIncidentDomainLogic.CanStoreZombieIncident : " + 
               'zombieIncident has invalid data, ensure Heading/Text are filled in correctly");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
        }

        if (!zombieIncident.GeoLocation.IsValid())
        {
            geoLocationValid = false;
            string error = ("ZombieIncidentDomainLogic.CanStoreZombieIncident : " + 
               "geoLocation has invalid data, ensure Latitude/Longiture are filled in correctly");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
        }

        return incidentValid && geoLocationValid;

    }

    public bool IsValidSearch(string propertyName, SearchType searchType, string searchValue)
    {
        if (string.IsNullOrEmpty(propertyName))
        {
            string error = ("ZombieIncidentDomainLogic.IsValidSearch : propertyName can not be null");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
            throw new BusinessLogicException("Search for Zombie Incident is invalid. 'PropertyName' is empty");
        }

        if (string.IsNullOrEmpty(searchValue))
        {
            string error = ("ZombieIncidentDomainLogic.IsValidSearch : searchValue can not be null");
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Business Logic").Error(error);
            throw new BusinessLogicException("Search for Zombie Incident is invalid. 'SearchValue' is empty");
        }

        return true;
    }
}

工作单元/通用存储库/持久化

本文的演示代码使用Fluent NHibernate作为其对象关系映射器(ORM),这使我们能够避免SQL,而只需处理业务对象或数据传输对象(DTO)。NHibernate自带工作单元,它允许我们在事务上下文中进行操作。这是通过使用ISession实现的。所以我们将利用这一点。

为了在演示代码中使用Fluent NHibernate,我想让它所有东西都能够轻松替换,所以我使用了IOC容器的元素来确保相关的NHibernate助手类/ISession类和存储库都从IOC容器中获取。

我们将分别查看所有这些部分,但现在这是与NHibernate相关的IOC容器设置

public class StandardInstaller : IWindsorInstaller
{
    public void Install(IWindsorContainer container, IConfigurationStore store)
    {
        string dbConnectionString = ConfigurationManager.ConnectionStrings["ZombieDB"].ConnectionString;

        container.AddFacility<WcfFacility>()
        .Register(
            Component.For<NHibernateHelper>().DependsOn(new
                    {
                        connectionString = dbConnectionString
                    }).LifeStyle.Singleton,
            Component.For<Func<ISessionFactory>>().Instance(() => 
              container.Resolve<NHibernateHelper>().SessionFactory).LifeStyle.Singleton,
            Component.For<Func<ISession>>().Instance(() => 
              ((INHibSessionProvider)container.Resolve<IUnitOfWork>()).Session).LifeStyle.Singleton,
            Component.For<IUnitOfWork>().ImplementedBy<NHibernateUnitOfWork>().LifeStyle.Singleton,
            Component.For<IRepository<ZombieIncident>>().ImplementedBy<NHibernateRepository<ZombieIncident>>().LifeStyle.Singleton,
            Component.For<IRepository<GeoLocation>>().ImplementedBy<NHibernateRepository<GeoLocation>>().LifeStyle.Singleton,
            ....
            ....
            ....

        );
    }
}

这都是相当标准的IOC行为,除了Func委托。它们只是工厂委托,用于为其他IOC注册的组件提供值之一。

工作单元:ISession

工作单元通过使用附带的演示代码NHibernateUnitOfWork类来实现,该类如下所示

public interface INHibSessionProvider
{
    ISession Session { get; }
}

public class NHibernateUnitOfWork : IUnitOfWork, INHibSessionProvider
{
    private ITransaction transaction;
    public ISession Session { get; private set; }

    public NHibernateUnitOfWork(Func<ISessionFactory> sessionFactory)
    {
        Session = sessionFactory().OpenSession();
        Session.FlushMode = FlushMode.Auto;
        this.transaction = Session.BeginTransaction(IsolationLevel.ReadCommitted);
    }


    public void Dispose()
    {
        if(Session.IsOpen)
        {
            Session.Close();
        }
    }

    public void Commit()
    {
        if(!transaction.IsActive)
        {
            throw new InvalidOperationException("No active transation");
        }
        transaction.Commit();
    }

    public void Rollback()
    {
        if(transaction.IsActive)
        {
            transaction.Rollback();
        }
    }
}

这个类本身就很好理解,它允许在一个Transaction中完成一些工作,然后提交或回滚。

它确实使用了另一个助手类,该类由IOC容器通过使用Func委托作为工厂来提供。

public class NHibernateHelper
{

    private readonly string connectionString;
    private ISessionFactory sessionFactory;

    public ISessionFactory SessionFactory
    {
        get { return sessionFactory ?? (sessionFactory = CreateSessionFactory()); }
    }

    public NHibernateHelper(string connectionString)
    {
        this.connectionString = connectionString;
    }

    private ISessionFactory CreateSessionFactory()
    {
        return Fluently.Configure()
            .Database(MsSqlConfiguration.MsSql2008
                .ConnectionString(connectionString))
            .Mappings(m => m.FluentMappings.AddFromAssembly(Assembly.GetExecutingAssembly()))
            .BuildSessionFactory();
    }
}

通用存储库

我尝试使用的另一个东西是存储库模式,我使用了Castle支持开放泛型的能力。

以下是您将在附带代码中找到的存储库的示例,其中NHibernateISession(工作单元)被传递给存储库。这是为了让多个存储库之间的多个操作都能参与到同一个Transaction中。至少这是想法。有时你必须调用当前工作单元的Commit(),这没关系,然后你可以再次使用你的存储库,通过传递给存储库的NHibernateISession(工作单元)进行新的Transaction

public class NHibernateRepository<T> : IRepository<T> where T : class
{

    private readonly ISession Session;

    public NHibernateRepository(Func<ISession> session)
    {
        Session = session();
    }

    public T GetById(int id)
    {
        return Session.Load<T>(id);
    }

    public IQueryable<T> GetAll()
    {
        return Session.Query<T>();
    }


    public T FindBy(System.Linq.Expressions.Expression<System.Func<T, bool>> expression)
    {
        return FilterBy(expression).Single();
    }

    public IQueryable<T> FilterBy(System.Linq.Expressions.Expression<System.Func<T, bool>> expression)
    {
        return GetAll().Where(expression).AsQueryable();
    }


    public void InsertOnSubmit(T entity)
    {
        Session.Save(entity);
    }

    public void DeleteOnSubmit(T entity)
    {
        Session.Delete(entity);
    }

    public void SubmitChanges()
    {
        Session.Flush();
    }
}

以下是一个示例,展示了ISession基础的工作单元和存储库如何协同工作。

public SaveZombieIncidentTask(
    IZombieIncidentDomainLogic zombieIncidentLogic,
        IRepository<dtos.ZombieIncident> zombieRepository, 
    IRepository<dtos.GeoLocation> geoLocationRepository,
        IUnitOfWork unitOfWork, 
    ZombieIncidentInfo zombieIncidentInfo)
{
    this.zombieIncidentLogic = zombieIncidentLogic;
    this.zombieRepository = zombieRepository;
    this.geoLocationRepository = geoLocationRepository;
    this.unitOfWork = unitOfWork;
    this.zombieIncidentInfo = zombieIncidentInfo;
}

public override Response Execute()
{
    try
    {
        bool result = zombieIncidentLogic.CanStoreZombieIncident(zombieIncidentInfo);
        if (result)
        {
            ZombieIncident zombieIncident = ZombieIncidentDTOMapper.ToDTO(zombieIncidentInfo);
                    
            zombieRepository.InsertOnSubmit(zombieIncident);
            zombieRepository.SubmitChanges();
                    
            geoLocationRepository.InsertOnSubmit(zombieIncident.GeoLocation);
            geoLocationRepository.SubmitChanges();

            unitOfWork.Commit();
            unitOfWork.Dispose();
            return new SaveZombieIncidentResponse(result);
        }
        return new SaveZombieIncidentResponse(false);
    }
    catch (BusinessLogicException bex)
    {
        throw;
    }
    catch (Exception ex)
    {
        WcfExemplar.Common.Logging.LogManager.Instance.Logger("Tasks").ErrorFormat("{0}\r\n{1}",ex.Message, ex.StackTrace);
        throw;
    }
}

DTO对象

你们中一些眼尖的人会注意到,我上面没有使用共享的WCF DataContract基础对象。我实际上使用了一些更轻量级的数据传输对象(DTO),我只用于与NHibernate和数据库通信。

这是演示应用程序项目中使用的两个DTO,看看它们是多么干净,并且允许NHibernate直接填充属性。我决定引入一个PersistableBase类,它处理持久化实体可能需要的常见内容,例如

  • ID
  • 版本(乐观并发)
public abstract class PersistableBase
{
    public virtual int Id { get; set; }
    public virtual byte[] Version { get; set; }
}

public class ZombieIncident : PersistableBase
{
    public virtual string Heading { get; set; }
    public virtual string Text { get; set; }
    public virtual GeoLocation GeoLocation { get; set; }
}

public class GeoLocation
{
    private int ZombieIncidentId { get; set; }
    private ZombieIncident ZombieIncident { get; set; }

    protected GeoLocation() 
    {
    }
        
    public GeoLocation(ZombieIncident zombieIncident)
    {
        ZombieIncident = zombieIncident;
    }

    public virtual double Latitude { get; set; }
    public virtual double Longitude { get; set; }
}

你可能会想,这些人是如何被填充的?嗯,使用Fluent NHibernate的答案在于Mapper文件的使用。这是你刚刚看到的两个DTO的两个mapper文件(其中我还使用了一个基础mapper类,名为PersistanceMapping<TDomainEntity>)。

public abstract class PersistenceMapping<TDomainEntity> : 
    ClassMap<TDomainEntity> where TDomainEntity : PersistableBase
{
    public PersistenceMapping()
    {
        Id(x => x.Id).GeneratedBy.Identity();
        OptimisticLock.Version();
        Version(x => x.Version).Column("Version").Generated.Always();
            
    }
}


public class ZombieIncidentMap : PersistenceMapping<ZombieIncident>
{
    public ZombieIncidentMap() : base()
    {
        Table("ZombieIncidents");
        Map(x => x.Heading);
        Map(x => x.Text);
        HasOne(x => x.GeoLocation).Cascade.All();
    }
}


public class GeoLocationMap : ClassMap<GeoLocation>
{
    public GeoLocationMap()
    {
        Table("GeoLocations");
        Id(Reveal.Property<GeoLocation>("ZombieIncidentId")).GeneratedBy.Foreign("ZombieIncident");
        HasOne(Reveal.Property<GeoLocation, ZombieIncident>("ZombieIncident")).Constrained().ForeignKey();
        Map(x => x.Latitude);
        Map(x => x.Longitude);
    }
}

Fluent NHibernate一对一映射

上面两个Fluent NHibernate文件用于创建一个具有GeoLocation对象的1对1关系的ZombieIncident。这听起来很简单,但事实证明比我想象的要难得多,所以我开始谷歌搜索,并找到了以下链接,它很棒,我用它来实现这个功能。

那么,这基本上就结束了服务器端代码,很多内容可能要等你自己查看代码后才能理解。

接下来我们将讨论UI。在这样做之前,我想说这篇文章更多的是关于尝试设计一个好的服务端设计,UI只是调用服务端代码的载体。

然而,我想这可能是个好时机,也给许多使用我Cinch MVVM框架的人一个更丰富的例子,并尝试创建一个类似Metro的应用,所以我确实这样做了,我认为你看到的演示UI代码在展示我的Cinch MVVM框架和PRISM协同工作方面做得不错。

尽管UI代码功能齐全,并且是使用MVVM和PRISM的丰富示例,但我不会花费太多时间去深入研究它的内部。我只会谈论关键点,你可以查看代码,或者我写的关于Cinch的其他文章,或者查看PRISM文档,如果你想要更多信息。

客户端:WPF

正如刚才所说,客户端是一个WPF应用程序,它使用了以下技术

  • Cinch:我自己的MVVM框架,你可以阅读更多关于它的信息:cinch.codeplex.com
  • PRISM:这是一个模块化框架,恰好与我的Cinch库配合得很好

如果你不熟悉这两者中的任何一个,我建议你查看cinch.codeplex.com并关注那里的Version 2文章链接。至于PRISM,我建议你阅读PRISM网站上提供的(非常好的)文档。

那么UI实际做什么

这是UI允许用户做什么的总结

  • 搜索项目(我存储了僵尸数据,其中包含每个僵尸事件的标题/描述和地理位置数据)
  • 添加新的ZombieIncident
  • 在地图上查看ZombieIncident项目(我选择使用Bing Maps)
  • 使用Rx显示全球僵尸事件的RSS Feed(算是一个奖励吧)

UI显然是MVVM模式,并大量使用了以下PRISM功能

  • 模块
  • 区域

有四个主要区域/模块,它们执行以下操作

  1. ShellModule:提供一个处理MainWindow命令的模块
  2. MapModule:提供一个模块,在2D Bing地图上显示僵尸事件,并允许创建新的僵尸事件
  3. RSSFeedModule:提供一个模块,使用Reactive Framework (Rx) 从僵尸网站获取僵尸事件
  4. SearchModule:提供一个模块,允许用户搜索僵尸事件,并在Panorama控件中显示结果,还允许用户查看这些事件的详细信息

重要提示

UI使用Bing Maps,所以请确保获取您自己的API密钥,并执行本文开头“先决条件”部分提到的操作。此外,由于此UI使用Bing Maps(以及RX从RSS Feed获取僵尸数据),您必须具有良好的互联网连接才能使应用程序按预期工作。

共享DLL

我认为人们在刚开始使用WCF时经常做的一件事是使用服务reference.cs文件。这要么是通过Visual Studio的“添加服务引用”菜单项创建的,要么是使用SvcUtil.exe创建的。使用Reference.cs的问题在于,您只能看到任何DataContract/DataMembers被序列化,但构造函数和方法不会被序列化,这真的很糟糕。

更好的方法是简单地分离WCF服务和WCF服务客户端之间的通用部分。所以通常这会包括:

  • DataContract业务对象
  • ServiceContract(WCF服务接口)
  • FaultContracts,以便WCF客户端可以处理WCF服务引发的故障

对于演示应用程序,以下两个DLL在WCF服务和WPF客户端之间共享。希望从下面两个屏幕截图中的文件夹名称中应该可以清楚地看出正在共享什么。

WcfExamplar.Contracts

WcfExemplar.Common

通过共享此DLL,WPF客户端可以执行完全疯狂的事情,比如调用共享DLL中对象的实际方法。另一个非常离谱的事情是,WPF客户端甚至可以使用共享业务对象的构造函数。太不可思议了。

现在,如果你使用过Reference.cs(由Visual Studio或SvcUtil.exe生成),你就不会得到任何方法。长话短说,永远不要使用Reference.cs,它是邪恶的,什么也给不了你。只需在DLL中共享通用内容。它提供了Reference.cs所提供的一切,甚至更多。例如,它能够知道如何构造对象使其处于有效状态,而不是猜测您可能需要设置哪些属性才能成功创建在Reference.cs中可能找到的对象。

公共代理

为了促进与WCF服务的通信,WPF客户端使用一个简单的代理类,如下所示

[PartCreationPolicy(CreationPolicy.Shared)]
[Export(typeof(IServiceInvoker))]
public class WCFServiceInvoker : IServiceInvoker
{
    private static ChannelFactoryManager _factoryManager = new ChannelFactoryManager();
    private static ClientSection _clientSection = 
      ConfigurationManager.GetSection("system.serviceModel/client") as ClientSection;

    public R CallService<R>(Request request) where R : Response
    {
        var endpointNameAddressPair = GetEndpointNameAddressPair(typeof(IGateway));
        IGateway proxy = _factoryManager.CreateChannel<IGateway>(endpointNameAddressPair.Key, endpointNameAddressPair.Value);
        ICommunicationObject commObj = (ICommunicationObject)proxy;
        try
        {
            return (R)proxy.ExecuteRequest(request);
        }
        catch (FaultException<GenericFault> gf)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger(
        "WCFServiceInvoker").Error("A Gateway FaultException<GenericFault> occured", gf.InnerException);
            throw new ApplicationException("A Gateway FaultException<GenericFault> occured", gf.InnerException);
        }
        catch (FaultException<BusinessLogicFault> bf)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger(
        "WCFServiceInvoker").Error("A Gateway FaultException<BusinessLogicFault> occured", bf.InnerException);
            throw new BusinessLogicException(bf.Message);
        }
        catch (Exception ex)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger(
        "WCFServiceInvoker").Error("A Gateway Exception occured", ex);
            throw new Exception("A Gateway Exception occured", ex);
        }
        finally
        {
            try
            {
                if (commObj.State != CommunicationState.Faulted)
                {
                    commObj.Close();
                }
            }
            catch
            {
                commObj.Abort();
            }
        }
    }

    private static KeyValuePair<string, string> GetEndpointNameAddressPair(Type serviceContractType)
    {
        var configException = new ConfigurationErrorsException(string.Format(
        "No client endpoint found for type {0}. Please add the section <client>" + 
        "<endpoint name=\"myservice\" address=\"http://address/\" binding" + 
        "=\"basicHttpBinding\" contract=\"{0}\"/></client> in the config file.", 
        serviceContractType));

        if (((_clientSection == null) || (_clientSection.Endpoints == null)) || (_clientSection.Endpoints.Count < 1))
        {
            throw configException;
        }
        foreach (ChannelEndpointElement element in _clientSection.Endpoints)
        {
            if (element.Contract == serviceContractType.ToString())
            {
                return new KeyValuePair<string, string>(element.Name, element.Address.AbsoluteUri);
            }
        }
        throw configException;
    }

}

这个辅助类的几个关键点是

  • 它捕获已知的FaultContract并记录它们
  • 它还能够将某些Exception升级到调用的WPF客户端代码,以便显示给用户
  • 它将自动从App.Config中查找契约的地址

这个辅助代理类还利用了另一个处理ChannelFactory创建的辅助类。这个额外的辅助类如下所示

public class ChannelFactoryManager : IDisposable
{
    private static Dictionary<Type, ChannelFactory> factories = new Dictionary<Type, ChannelFactory>();
    private static readonly object _syncRoot = new object();

    public virtual T CreateChannel<T>() where T : class
    {
        return CreateChannel<T>("*", null);
    }

    public virtual T CreateChannel<T>(string endpointConfigurationName) where T : class
    {
        return CreateChannel<T>(endpointConfigurationName, null);
    }

    public virtual T CreateChannel<T>(
        string endpointConfigurationName, string endpointAddress) where T : class
    {
        T local = GetFactory<T>(endpointConfigurationName, endpointAddress).CreateChannel();
        ((IClientChannel)local).Faulted += ChannelFaulted;
        return local;
    }

    protected virtual ChannelFactory<T> GetFactory<T>(
        string endpointConfigurationName, string endpointAddress) where T : class
    {
        lock (_syncRoot)
        {
            ChannelFactory factory;
            if (!factories.TryGetValue(typeof(T), out factory))
            {
                factory = CreateFactoryInstance<T>(endpointConfigurationName, endpointAddress);
                factories.Add(typeof(T), factory);
            }
            return (factory as ChannelFactory<T>);
        }
    }

    private ChannelFactory CreateFactoryInstance<T>(
        string endpointConfigurationName, string endpointAddress)
    {
        ChannelFactory factory = null;
        if (!string.IsNullOrEmpty(endpointAddress))
        {
            factory = new ChannelFactory<T>(
                endpointConfigurationName, new EndpointAddress(endpointAddress));
        }
        else
        {
            factory = new ChannelFactory<T>(endpointConfigurationName);
        }
        factory.Faulted += FactoryFaulted;
        factory.Open();
        return factory;
    }

    private void ChannelFaulted(object sender, EventArgs e)
    {
        IClientChannel channel = (IClientChannel)sender;
        try
        {
            channel.Close();
        }
        catch
        {
            channel.Abort();
        }
        throw new ApplicationException("Exc_ChannelFailure");
    }

    private void FactoryFaulted(object sender, EventArgs args)
    {
        ChannelFactory factory = (ChannelFactory)sender;
        try
        {
            factory.Close();
        }
        catch
        {
            factory.Abort();
        }
        Type[] genericArguments = factory.GetType().GetGenericArguments();
        if ((genericArguments != null) && (genericArguments.Length == 1))
        {
            Type key = genericArguments[0];
            if (factories.ContainsKey(key))
            {
                factories.Remove(key);
            }
        }
        throw new ApplicationException("Exc_ChannelFactoryFailure");
    }

    public void Dispose()
    {
        Dispose(true);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            lock (_syncRoot)
            {
                foreach (Type type in factories.Keys)
                {
                    ChannelFactory factory = factories[type];
                    try
                    {
                        factory.Close();
                        continue;
                    }
                    catch
                    {
                        factory.Abort();
                        continue;
                    }
                }
                factories.Clear();
            }
        }
    }
}

这个辅助类的几个关键点是

  • 它持有一个未发生故障的ChannelFactory实例的Dictionary。不过在这个演示代码的情况下,它只会包含一个键,因为只有一个服务。
  • 它将自动删除任何发生故障的ChannelFactory

我实际上是从一个StackOverflow论坛帖子中获得了这两个类的基本功能,您可以在这里阅读更多内容: http://stackoverflow.com/questions/3200197/creating-wcf-channelfactoryt

与WCF服务通信的服务

为了与WCF服务通信,WPF客户端使用服务。这些服务通常被抽象在一个接口后面。这样做的目的是在一定程度上促进更好的测试。因为如果你的WCF服务不可用,我们可以注入一些模拟服务,它们可以与内存存储通信,而不是直接与WCF服务通信。

这是一个典型的服务

namespace WcfExemplar.WpfClient.RSSFeedModule.Services
{
    public interface IRssFeedProvider
    {
        void LoadFeed (Action<IEnumerable<Item>> subscribeCallback, Action<Exception> errorCallback);
    }
}

namespace WcfExemplar.WpfClient.MapModule.Services
{
    public interface IZombieIncidentProvider
    {
        bool Save(ZombieIncidentViewModel newIncident);
        void LoadIncidents(Action<IEnumerable<ZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback);
    }
}


namespace WcfExemplar.WpfClient.SearchModule.Services
{
    public enum SearchType
    {
        Contains = 1,
        StartsWith = 2,
        EndsWith = 3,
        ShowAll = 4
    };

    public interface ISearchProvider
    {
        void SearchIncidents(
            string propertyName,
            SearchType searchType,
            string searchValue,
            Action<IEnumerable<PanoramaZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback);
    }
}

有些人可能会注意到,这些服务方法不直接返回任何结果。原因是我们要保持UI的响应性。

所有这些UI服务都是异步的(使用Reactive Extensions (RX) 或 Task Parallel Library (TPL)),以确保UI保持响应,从而可以执行诸如在后台工作时显示忙碌指示器之类的操作。Action<T>委托作为工作完成或发生错误时的回调。

你可能还会注意到(如果你检查代码库)的是,这些服务通过MEF提供给演示应用程序中的ViewModels。这就是使用Cinch V2和PRISM 4在一起时所获得的免费功能。它们确实配合得很好。

我们将在下面显示的3个子部分中更详细地讨论所使用的服务。

RSS Feed模块

重要提示

您可以单击下面的图像来查看图像的放大版本

RSS Feed模块从以下URL读取数据: http://www.zombiereportingcenter.com/feed/,使用Reactive Extensions (RX)。

在这里,您可以单击“查看完整的RSS Feed”按钮来查看完整的RSS Feed,如下所示。

这是一个我以前讲过的带有摩擦力的ScrollViewer,所以点击并释放以获得摩擦力的乐趣。

生成这些屏幕数据的绝大部分工作是由RssFeedProvider完成的,如下所示

[PartCreationPolicy(CreationPolicy.NonShared)]
[ExportService(ServiceType.Runtime, typeof(IRssFeedProvider))]
public class RssFeedProvider : IRssFeedProvider
{
    private string feedUri = "http://www.zombiereportingcenter.com/feed/";


    public void LoadFeed(Action<IEnumerable<Item>> subscribeCallback, Action<Exception> errorCallback)
    {
        Func<IObservable<string>> readZombieFeed = () =>
        {
            var request = (HttpWebRequest)HttpWebRequest.Create(new Uri(feedUri));
            var zombieFeedAsyncObs = Observable.FromAsyncPattern<WebResponse>(request.BeginGetResponse, request.EndGetResponse);
            return zombieFeedAsyncObs().Select(res => WebResponseToString(res));
        };

        var sub = Observable.Interval(TimeSpan.FromSeconds(5))
            .SelectMany(txt => readZombieFeed())
            .Select(response => ParseZombieFeedData(response))
            .Subscribe(zombieResults => subscribeCallback(FilterResults(zombieResults)), ex => errorCallback(ex));
    }


    private IEnumerable<Item> FilterResults(IEnumerable<Channel> zombieResults)
    {
        List<Item> items = new List<Item>();
        foreach (Channel channel in zombieResults)
        {
            items.AddRange(channel.Items);
        }
        return items;
    }


    private string WebResponseToString(WebResponse webResponse)
    {
        HttpWebResponse response = (HttpWebResponse)webResponse;
        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
        {
            return reader.ReadToEnd();
        }
    }


    private IEnumerable<Channel> ParseZombieFeedData(string response)
    {
        var xdoc = XDocument.Parse(response);
        return from channels in xdoc.Descendants("channel")
            select new Channel
            {
                Title = channels.Element("title") != null ? channels.Element("title").Value : "",
                Link = channels.Element("link") != null ? channels.Element("link").Value : "",
                Description = channels.Element("description") != null ? channels.Element("description").Value : "",
                Items = from items in channels.Descendants("item")
                    select new Item
                    {
                        Title = items.Element("title") != null ? items.Element("title").Value : "",
                        Link = items.Element("link") != null ? items.Element("link").Value : "",
                        Description = items.Element("description") != null ? items.Element("description").Value : "",
                        Guid = (items.Element("guid") != null ? items.Element("guid").Value : "")
                    }
            };
    }

}

可以看出,它执行了几个步骤来获得结果

  1. 它从异步Web请求创建一个Observable,并使用RxInterval方法刷新它
  2. 然后使用标准的XLINQ操作将数据解析成Channel/Item对象,您可以在代码中看到这些对象。
  3. 由于这个模块所做的只是解析一些网站的结果,所以这个模块不需要WCF,它一切都很方便且独立。

MapModule

此模块在Bing地图上显示所有现有僵尸事件。只是为了记录,如果您遵循了“入门”部分,您应该有一些现有的僵尸事件,因为我提供了一些虚拟数据来填充数据库。

以下是此模块可能实现的功能

  1. 您可以使用右侧的控件缩放地图
  2. 您可以使用右侧的控件更改地图类型
  3. 通过将鼠标悬停在显示的僵尸图标上,查看现有僵尸事件的工具提示
  4. 当您双击地图上的某个位置时,一个新的僵尸面板将从右侧滑入,允许您创建新的僵尸事件(如下所示)

让我们分解一下这个模块所做的事情,并更详细地检查代码。

使用Bing Maps

Bing地图作为一个标准的WPF控件可用,这意味着您只需在XAML中放入类似这样的内容。

<!-- CredentialsProvider="INSERT_YOUR_BING_MAPS_KEY" -->
<bing:Map x:Name="map"
            Grid.Row="1"
            Grid.Column="0"
            PreviewMouseDoubleClick="Map_PreviewMouseDoubleClick"
            CredentialsProvider="INSERT_YOUR_BING_MAPS_KEY"
            ZoomLevel="4.0"
            AnimationLevel="Full"
            Mode="AerialWithLabels"
            Center="37.806029,-122.407007" />

因此,有了地图,添加数据和控制地图都可以通过一些简单的代码隐藏来实现(是的,即使我是一个MVVM推崇者,有时也会在我认为合适的地方使用代码隐藏)。以下是ZombieIncidentMapView的完整代码隐藏。

[Export]
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class ZombieIncidentMapView : UserControl, IZombieIncidentMapView
{
    private double minZoom = 0;
    private double maxZoom = 20;

    LocationConverter locConverter = new LocationConverter();

    public ZombieIncidentMapView()
    {
        InitializeComponent();
        map.Focus();
        Cinch.Mediator.Instance.Register(this);
    }

    public void CreatePinsForZombieIncidents(IEnumerable<ZombieIncidentViewModel> zombieIncidents)
    {
        map.Children.Clear();
        foreach (var zombieIncident in zombieIncidents)
        {
            MapPin pin = new MapPin() { ZombieIncident = zombieIncident };
            pin.SetValue(MapLayer.PositionProperty, new Location(zombieIncident.Latitude, zombieIncident.Longitude, 0));
            pin.SetValue(MapLayer.PositionOriginProperty, PositionOrigin.Center);
            map.Children.Add(pin);
        }
    }


    public void AddNewlyCreatedIncident(ZombieIncidentViewModel zombieIncident)
    {
        VisualStateManager.GoToState(this, "HideAddState", true);
        MapPin pin = new MapPin() { ZombieIncident = zombieIncident };
        pin.SetValue(MapLayer.PositionProperty, new Location(zombieIncident.Latitude, zombieIncident.Longitude, 0));
        pin.SetValue(MapLayer.PositionOriginProperty, PositionOrigin.Center);
        map.Children.Add(pin);

    }
 
    [Cinch.MediatorMessageSink("HideAddIncidentPaneMessage")]
    public void OnHideAddIncidentPaneMessage(bool dummy)
    {
        VisualStateManager.GoToState(this, "HideAddState", true);
    }

        
    private void Map_PreviewMouseDoubleClick(object sender, MouseButtonEventArgs e)
    {
        e.Handled = true;
        Point mousePosition = e.GetPosition(this);
        Location pinLocation = map.ViewportPointToLocation(mousePosition);

        addNewGrid.Content = new AddNewIncidentView() 
            { 
                GeoLocation = new Tuple<double, double>(pinLocation.Latitude, pinLocation.Longitude) 
            };
        //Shows add new zombie incident panel or RHS of screen
        VisualStateManager.GoToState(this, "ShowAddState", true);
    }

    private void btnZoomIn_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var zoom = map.ZoomLevel + 2;
            map.ZoomLevel = zoom > maxZoom ? maxZoom : zoom;
        }
        //map bad
        catch { }
    }

    private void btnZoomOut_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            var zoom = map.ZoomLevel - 2;
            map.ZoomLevel = zoom < minZoom ? minZoom : zoom;
        }
        //map bad
        catch { }
    }

    private void ContextMenu_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            MenuItem menuItem = (MenuItem)e.OriginalSource;
            switch (menuItem.Header.ToString())
            {
                case "Aerial":
                    map.Mode = new AerialMode(true);
                    break;
                case "Road":
                    map.Mode = new RoadMode();
                    break;
            }
        }
        //map bad
        catch { }
    }

    private void btnMapType_Click(object sender, RoutedEventArgs e)
    {
        (sender as Button).ContextMenu.IsOpen = true;
    }
}

这非常直接,我希望您能看到这段代码只是操作Bing地图控件,并允许显示现有僵尸事件/通过一个新视图允许添加新僵尸事件,该视图只是从此视图中显示/隐藏。

获取所有现有僵尸事件

为了从数据库中获取所有现有的僵尸事件,使用了以下WPF UI服务

public interface IZombieIncidentProvider
{
    bool Save(ZombieIncidentViewModel newIncident);
    void LoadIncidents(Action<IEnumerable<ZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback);
}

显然,我们需要关注LoadIncidents(..)方法。它如下所示

[PartCreationPolicy(CreationPolicy.NonShared)]
[ExportService(ServiceType.Runtime, typeof(IZombieIncidentProvider))]
public class ZombieIncidentProvider : IZombieIncidentProvider
{
    private IServiceInvoker serviceInvoker;

    [ImportingConstructor]
    public ZombieIncidentProvider(IServiceInvoker serviceInvoker)
    {
        this.serviceInvoker = serviceInvoker;
    }
        
 
    public void LoadIncidents(Action<IEnumerable<ZombieIncidentViewModel>> 
                successCallback, Action<Exception> errorCallback)
    {
        CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
        CancellationToken cancellationToken = cancellationTokenSource.Token;
        Task<bool> cancellationDelayTask = TaskHelper.CreateDelayTask(20000);
        cancellationDelayTask.ContinueWith(dt =>
        {
            cancellationTokenSource.Cancel();
        }, TaskContinuationOptions.OnlyOnRanToCompletion);

        try
        {
            Task<IEnumerable<ZombieIncidentViewModel>> searchTask = 
               Task.Factory.StartNew<IEnumerable<ZombieIncidentViewModel>>(() =>
            {
                var incidents = this.serviceInvoker.CallService<ZombieIncidentsResponse>(
                    new ZombieIncidentsRequest()).ZombieIncidents.ToList();
                List<ZombieIncidentViewModel> zombieIncidentViewModels = new List<ZombieIncidentViewModel>();
                foreach (var incident in incidents)
                {
                    zombieIncidentViewModels.Add(new ZombieIncidentViewModel(
                      incident.GeoLocation.Latitude, incident.GeoLocation.Longitude, incident.Heading, incident.Text));
                }

                return zombieIncidentViewModels;
            }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);

            //Sucess callback
            searchTask.ContinueWith(ant =>
            {
                successCallback(ant.Result); 
            }, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, 
                  TaskScheduler.FromCurrentSynchronizationContext());

            //Failure callback
            searchTask.ContinueWith(ant =>
            {
                LogManager.Instance.Logger("WpfClient.MapModule.ZombieIncidentProvider").Error(
                     "A timeout occurred whilst attempting to fetch the zombie incidents");
                errorCallback(new TimeoutException("A timeout occurred whilst attempting to fetch the zombie incidents"));
            }, cancellationToken, TaskContinuationOptions.NotOnRanToCompletion, 
                  TaskScheduler.FromCurrentSynchronizationContext());
        }
        catch(AggregateException ex)
        {
            LogManager.Instance.Logger("WpfClient.MapModule.ZombieIncidentProvider").Error(ex.Flatten().Message);
            errorCallback(new ApplicationException("A generic error occurred whilst trying to fetch the zombie incidents"));
        }
    }
}

上面的代码展示了几个我们将再次看到的方面

  1. 我们使用TPL来确保这项工作是通过ThreadPool完成的,以确保在工作进行时UI保持响应。
  2. 我们利用了前面提到的Service代理类。
  3. 我们利用回调Action<T>以在成功和失败时进行调用。
  4. 我们创建了一个新的延迟Task,它将在x段时间内取消实际的Task,如果它还没有完成。

所以这就是UI的工作方式。现在让我们将注意力转移到实际提供现有僵尸事件的WCFTask上。

WCF TaskZombieIncidentsTask就是发生的地方。这是整个类,我认为它非常直观,现在我们知道我们正在使用工作单元/存储库模式并利用Fluent NHibernate。

public class ZombieIncidentsTask : Task
{
    private IRepository<dtos.ZombieIncident> zombieRepository;
    private IUnitOfWork unitOfWork;

    public ZombieIncidentsTask(IRepository<dtos.ZombieIncident> customerRepository, IUnitOfWork unitOfWork)
    {
        this.zombieRepository = customerRepository;
        this.unitOfWork = unitOfWork;
    }

    public override Response Execute()
    {
        try
        {
            List<ZombieIncidentInfo> zombies = new List<ZombieIncidentInfo>();
            foreach (dtos.ZombieIncident zombie in zombieRepository.GetAll().ToList())
            {
                zombies.Add(ZombieIncidentDTOMapper.FromDTO(zombie));
            }
            return new ZombieIncidentsResponse(zombies);
        }
        catch (Exception ex)
        {
            return new ZombieIncidentsResponse(new List<ZombieIncidentInfo>());
        }
    }
}

保存新的僵尸事件

为了在数据库中创建新的僵尸事件,使用了以下WPF UI服务

public interface IZombieIncidentProvider
{
    bool Save(ZombieIncidentViewModel newIncident);
    void LoadIncidents(Action<IEnumerable<ZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback);
}

显然,我们需要关注Save(..)方法。它如下所示

[PartCreationPolicy(CreationPolicy.NonShared)]
[ExportService(ServiceType.Runtime, typeof(IZombieIncidentProvider))]
public class ZombieIncidentProvider : IZombieIncidentProvider
{
    private IServiceInvoker serviceInvoker;

    [ImportingConstructor]
    public ZombieIncidentProvider(IServiceInvoker serviceInvoker)
    {
        this.serviceInvoker = serviceInvoker;
    }
        
    public bool Save(ZombieIncidentViewModel newIncident)
    {
        ZombieIncidentInfo zombieIncidentInfo= new ZombieIncidentInfo(newIncident.Heading, newIncident.Text,
            new GeoLocationInfo(newIncident.Latitude,newIncident.Longitude));
        return this.serviceInvoker.CallService<SaveZombieIncidentResponse>(
        new SaveZombieIncidentRequest(zombieIncidentInfo)).Success;
    }
 }

和以前一样,我们利用了前面提到的Service代理类。

所以这就是UI的工作方式。现在让我们将注意力转移到实际提供现有僵尸事件的WCF任务上,好吗?

WCF TaskSaveZombieIncidentTask就是发生的地方。

public class SaveZombieIncidentTask : Task
{
    private IZombieIncidentDomainLogic zombieIncidentLogic;
    private IRepository<dtos.ZombieIncident> zombieRepository;
    private IRepository<dtos.GeoLocation> geoLocationRepository;
    private IUnitOfWork unitOfWork;
    private ZombieIncidentInfo zombieIncidentInfo;

    public SaveZombieIncidentTask(IZombieIncidentDomainLogic zombieIncidentLogic,
        IRepository<dtos.ZombieIncident> zombieRepository, IRepository<dtos.GeoLocation> geoLocationRepository,
        IUnitOfWork unitOfWork, ZombieIncidentInfo zombieIncidentInfo)
    {
        this.zombieIncidentLogic = zombieIncidentLogic;
        this.zombieRepository = zombieRepository;
        this.geoLocationRepository = geoLocationRepository;
        this.unitOfWork = unitOfWork;
        this.zombieIncidentInfo = zombieIncidentInfo;
    }

    public override Response Execute()
    {
        try
        {
            bool result = zombieIncidentLogic.CanStoreZombieIncident(zombieIncidentInfo);
            if (result)
            {
                ZombieIncident zombieIncident = ZombieIncidentDTOMapper.ToDTO(zombieIncidentInfo);
                    
                zombieRepository.InsertOnSubmit(zombieIncident);
                zombieRepository.SubmitChanges();
                    
                geoLocationRepository.InsertOnSubmit(zombieIncident.GeoLocation);
                geoLocationRepository.SubmitChanges();

                unitOfWork.Commit();
                unitOfWork.Dispose();
                return new SaveZombieIncidentResponse(result);
            }
            return new SaveZombieIncidentResponse(false);
        }
        catch (BusinessLogicException bex)
        {
            throw;
        }
        catch (Exception ex)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger(
                 "Tasks").ErrorFormat("{0}\r\n{1}",ex.Message, ex.StackTrace);
            throw;
        }
    }
}

和以前一样,我们只是利用了我们已经看到的东西

  1. 业务逻辑:确保新的僵尸业务对象对于保存是有效的
  2. DTO:用于写入数据库的传输对象
  3. 工作单元:允许在一个事务中完成工作
  4. 存储库:允许我们抽象数据库操作,我们只需添加到存储库即可

搜索模块

该模块允许用户构建一个搜索,该搜索用于在磁贴式Panorama控件中查看匹配的僵尸事件,这是一个我在以前的文章中构建的控件。

从上面的图表中可以看出,您有几个选项可以构建您的搜索

  • 您可以选择搜索属性
    • 标题
    • 文本
  • 您可以选择搜索类型(其中一些还需要搜索值)
    • Contains
    • StartsWith
    • EndsWith
    • ShowAll(默认)

在后台进行某些操作时,UI将显示这个动画旋转器

这是结果到达时的样子

当您单击其中一个磁贴时,一个信息面板将从屏幕右侧动画出现,其中显示了一些文本和一个迷你地图。

这是显示文本数据的面板

这是在迷你地图上显示数据的面板

为了在数据库中搜索僵尸事件,使用了以下WPF UI服务

public enum SearchType
{
    Contains = 1,
    StartsWith = 2,
    EndsWith = 3,
    ShowAll = 4
};

public interface ISearchProvider
{
    void SearchIncidents(
        string propertyName,
        SearchType searchType,
        string searchValue,
        Action<IEnumerable<PanoramaZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback);
}

实际代码服务代码如下所示

[PartCreationPolicy(CreationPolicy.NonShared)]
[ExportService(ServiceType.Runtime, typeof(ISearchProvider))]
public class SearchProvider : ISearchProvider
{
    private IServiceInvoker serviceInvoker;

    [ImportingConstructor]
    public SearchProvider(IServiceInvoker serviceInvoker)
    {
        this.serviceInvoker = serviceInvoker;
    }

    public void SearchIncidents(string propertyName, SearchType searchType, string searchValue,
        Action<IEnumerable<PanoramaZombieIncidentViewModel>> successCallback, Action<Exception> errorCallback)
    {
        CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
        CancellationToken cancellationToken = cancellationTokenSource.Token;
        Task<bool> cancellationDelayTask = TaskHelper.CreateDelayTask(20000);
        cancellationDelayTask.ContinueWith(dt =>
        {
            cancellationTokenSource.Cancel();
        }, TaskContinuationOptions.OnlyOnRanToCompletion);

        try
        {

            Task<IEnumerable<PanoramaZombieIncidentViewModel>> searchTask = 
               Task.Factory.StartNew<IEnumerable<PanoramaZombieIncidentViewModel>>(() =>
            {
                SearchZombieIncidentsRequest searchZombieIncidentsRequest = 
                  new SearchZombieIncidentsRequest(propertyName, 
                  (WcfExamplar.Contracts.SearchType)searchType, searchValue);

                var incidents = this.serviceInvoker.CallService<SearchZombieIncidentsResponse>(
                  searchZombieIncidentsRequest).ZombieIncidents.ToList();
                List<PanoramaZombieIncidentViewModel> zombieIncidentViewModels = new List<PanoramaZombieIncidentViewModel>();
                foreach (var incident in incidents)
                {
                    zombieIncidentViewModels.Add(new PanoramaZombieIncidentViewModel(
                      incident.GeoLocation.Latitude, incident.GeoLocation.Longitude, incident.Heading, incident.Text));
                }

                return zombieIncidentViewModels;
            }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);

            //Sucess callback
            searchTask.ContinueWith(ant =>
            {
                successCallback(ant.Result);
            }, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, 
                  TaskScheduler.FromCurrentSynchronizationContext());

            //Failure callback
            searchTask.ContinueWith(ant =>
            {
                LogManager.Instance.Logger("WpfClient.SearchModule.SearchProvider").Error(
                  "A timeout occurred whilst attempting to search for zombie incidents");
                errorCallback(new TimeoutException("A timeout occurred whilst attempting to search for zombie incidents"));
            }, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.FromCurrentSynchronizationContext());
        }
        catch (AggregateException ex)
        {
            LogManager.Instance.Logger("WpfClient.SearchModule.SearchProvider").Error(ex.Flatten().Message);
            errorCallback(new ApplicationException("A generic error occurred whilst trying to search for zombie incidents"));
        }
    }
}

和以前一样,我们利用了前面提到的Service代理类。

所以这就是UI的工作方式。现在让我们将注意力转移到实际提供现有僵尸事件的WCF任务上,好吗?

WCF Task“SearchZombieIncidentsTask”就是发生的地方。

public class SearchZombieIncidentsTask : Task
{
    private IZombieIncidentDomainLogic zombieIncidentLogic;
    private IRepository<dtos.ZombieIncident> zombieRepository;
    private IUnitOfWork unitOfWork;
    private string propertyName;
    private SearchType searchType;
    private string searchValue;


    public SearchZombieIncidentsTask(IZombieIncidentDomainLogic zombieIncidentLogic,
        IRepository<dtos.ZombieIncident> zombieRepository,
        IUnitOfWork unitOfWork, string propertyName, SearchType searchType, string searchValue)
    {
        this.zombieIncidentLogic = zombieIncidentLogic;
        this.zombieRepository = zombieRepository;
        this.unitOfWork = unitOfWork;
        this.propertyName = propertyName;
        this.searchType = searchType;
        this.searchValue = searchValue;
    }

    public override Response Execute()
    {
        try
        {
            List<ZombieIncidentInfo> zombies = new List<ZombieIncidentInfo>();

            if (searchType == SearchType.ShowAll)
            {

                foreach (dtos.ZombieIncident zombie in zombieRepository.GetAll().ToList())
                {
                    zombies.Add(ZombieIncidentDTOMapper.FromDTO(zombie));
                }
            }
            else
            {
                bool result = zombieIncidentLogic.IsValidSearch(propertyName, searchType, searchValue);
                if (result)
                {
                    var paramStart = Expression.Parameter(typeof(dtos.ZombieIncident), "x");
                    Expression<Func<dtos.ZombieIncident, bool>> searchExp = Expression.Lambda<Func<dtos.ZombieIncident, bool>>(
                                Expression.Call(Expression.Property(paramStart,
                                    typeof(dtos.ZombieIncident).GetProperty(propertyName).GetGetMethod()),
                                    typeof(String).GetMethod(searchType.ToString(), new Type[] { typeof(String) }),
                                    new Expression[] { Expression.Constant(searchValue, typeof(string)) }),
                        new ParameterExpression[] { paramStart });

                    foreach (dtos.ZombieIncident zombie in zombieRepository.FilterBy(searchExp).ToList())
                    {
                        zombies.Add(ZombieIncidentDTOMapper.FromDTO(zombie));
                    }
                }
            }
            return new SearchZombieIncidentsResponse(zombies);
        }
        catch (BusinessLogicException bex)
        {
            throw;
        }
        catch (Exception ex)
        {
            WcfExemplar.Common.Logging.LogManager.Instance.Logger("Tasks").ErrorFormat("{0}\r\n{1}",ex.Message, ex.StackTrace);
            throw;
        }
    }
}

和以前一样,我们只是利用了我们已经看到的东西

  1. 业务逻辑:确保新的僵尸搜索是有效的(即,具有所有必需的值)
  2. DTO:用于写入数据库的传输对象
  3. 工作单元:允许在一个事务中完成工作
  4. 存储库:允许我们抽象数据库操作,我们只需使用存储库。

需要注意的是,如果搜索类型是“ShowAll”,我们只返回所有现有事件,否则我们从Task的输入值构建一个动态的LambdaExpression,该表达式用于存储库,这将正确地查询数据库。

就这样

这就是我想说的全部。我还认为这可能是我最后一篇关于WPF的文章了,因为我想花一些时间来更新我的JavaScript技能,所以我将花一些时间在以下方面:

  • Node.js
  • Backbone.js
  • Underscore.js
  • D3.js
  • Raphael.js
  • Easle.js

仅举几例,所以一旦我开始/变得惊恐/请求帮助或者可能掌握了它们,您就可以期待看到一些关于这些主题的文章。

特别感谢

我想感谢以下人员

历史

  • 10/10/12:初稿
  • 17/10/12:
    • 添加了乐观并发Fluent NHibernate映射相关内容。
    • 引入了更好的Request/Task映射概念。感谢.NET Junkie读者,他提出了优秀的建议。
僵尸浏览器:一个从头到底的n层应用程序-CodeProject - 代码之家
© . All rights reserved.