目录
介绍
问题
问题1
问题2
问题3
解决方案
问题1——智能序列化
问题2——反向引用
问题3——映射更新
说明
让我们尝试使用最现代的技术创建一个简单的Web应用程序,看看我们可能会遇到什么问题。我将使用带有WebAPI的最新ASP.NET MVC和最新的NHibernate。请不要担心,所有技术也都适用于Entity Framework(可下载的ZIP档案也包含Entity Framework示例)。我将充分利用WebAPI的潜力——即浏览器和服务器之间的所有交互都将在JSON中异步进行。为了实现这一点,我将使用MVC JavaScript库——AngularJS。
请注意,本文仅关于后端。如果您对前端方面感到好奇,请访问此链接:带有Upida/Jeneva的ASP.NET MVC单页应用程序(前端/AngularJS)。
假设我们有一个包含两个表的简单数据库:Client 和Login,每个客户端可以有一对多的登录名。我的应用程序将包含三个页面——“客户端列表”,“创建客户端”和“编辑客户端”。“创建客户端”和“编辑客户端”页面将能够编辑客户端数据以及管理子登录列表。您可以在此处查看其工作方式。
首先,让我们定义域(或模型)类(映射在hbm文件中定义):
public class Client
{
public virtual int? Id { get; set; }
public virtual string Name { get; set; }
public virtual string Lastname { get; set; }
public virtual int? Age { get; set; }
public virtual ISet Logins { get; set; }
}
public class Login
{
public virtual int? Id { get; set; }
public virtual string Name { get; set; }
public virtual string Password { get; set; }
public virtual bool? Enabled { get; set; }
public virtual Client Client { get; set; }
}
现在,我可以创建数据访问层。首先,我必须将其注入与基础DAO类NHibernate的 SessionFactory,并且定义了基本的DAO操作:Save,Delete,Update,Load,Get等。
public class Daobase : IDaobase
{
public ISessionFactory SessionFactory { get; set; }
public void Save(T entity)
{
this.SessionFactory
.GetCurrentSession()
.Save(entity);
}
public void Update(T entity)
{
this.SessionFactory
.GetCurrentSession()
.Update(entity);
}
public ITransaction BeginTransaction()
{
return this.SessionFactory
.GetCurrentSession()
.BeginTransaction();
}
/* others basic methods */
}
我只有一个DAO类-—— ClientDao。
public class ClientDao : Daobase, IClientDao
{
public Client GetById(int id)
{
return this.SessionFactory
.GetCurrentSession()
.CreateQuery("from Client client left outer join fetch
client.Logins where client.Id = :id")
.SetParameter("id", id)
.SetResultTransformer(Transformers.DistinctRootEntity)
.UniqueResult();
}
public IList GetAll()
{
return this.SessionFactory
.GetCurrentSession()
.CreateQuery("from Client client left outer join fetch client.Logins")
.SetResultTransformer(Transformers.DistinctRootEntity)
.List();
}
}
DAO完成后,我们可以切换到服务层。服务通常负责打开和关闭事务。我只有一个服务类。它以受尊重的DAO类注入。
注意,Save和Update方法接受一个Client对象及其子级Logins,因此使用NHibernate级联(同时保留父级和子级)执行保存或更新。
public class ClientService : ICLientService
{
public IClientDao ClientDao { get; set; }
public Client GetById(int clientId)
{
Client item = this.ClientDao.GetById(clientId);
return item;
}
public List GetAll()
{
List items = this.ClientDao.getAll();
return items;
}
public void Save(Client item)
{
using(ITransaction tx = this.clientDao.BeginTransaction())
{
/* TODO: assign back-references of the child
Login objects - for each Login: item.Login[i].Client = item; */
this.ClientDao.Save(item);
tx.Commit();
}
}
public void Update(Client item)
{
using(ITransaction tx = this.clientDao.BeginTransaction())
{
Client existing = this.clientDao.GetById(item.getId());
/* TODO: copy changes from item to existing (recursively) */
this.ClientDao.Merge(existing);
tx.Commit();
}
}
}
让我们谈谈控制器。我将有两个控制器——一个用于HTML视图(MVC控制器),另一个用于JSON请求(API控制器)。它们都将被称为ClientController,但是将驻留在不同的命名空间中。MVC控制器将来自System.Web.Mvc.Controller,而API控制器将来自System.Web.Http.ApiController。MVC控制器将负责显示正确的视图。外观如下:
public class ClientController : System.Web.Mvc.Controller
{
public ActionResult Index()
{
return this.View();
}
public ActionResult Create()
{
return this.View();
}
public ActionResult Edit()
{
return this.View();
}
}
API控制器稍微复杂一点,因为它负责与数据库的交互。它被注入相应的服务层类。
public class ClientController : System.Web.Http.ApiController
{
public ClientService ClientService { get; set;}
public IList GetAll()
{
return this.ClientService.GetAll();
}
public Client GetById(int id)
{
return this.ClientService.GetById(id);
}
public void Save(Client item)
{
this.ClientService.Save(item);
}
public void Update(Client item)
{
this.ClientService.Update(item);
}
}
现在,我们几乎拥有了所需的一切。MVC控制器将为我们提供HTML和JavaScript,它们将与API控制器进行异步交互并从数据库中获取数据。AngularJS将帮助我们将获取的数据显示为漂亮的HTML。我假设您熟悉AngularJS(或KnockoutJS),尽管本文并不那么重要。您唯一必须知道的是——每个页面都以静态HTML和JavaScript加载(没有任何服务器端脚本),在加载后,它与API控制器进行交互,以通过JSON从JSON异步加载数据库中所需的所有数据。而AngularJS有助于显示JSON作为美丽的HTML。
现在,让我们谈谈当前实现中面临的问题。
第一个问题是序列化。从API控制器返回的数据被序列化为JSON。您可以在以下两种API控制器方法中看到它:
public class ClientController : System.Web.Http.ApiController
{ ....
public IList GetAll()
{
return this.ClientService.GetAll();
}
public Client GetById(int id)
{
return this.ClientService.GetById(id);
}
Client类是一个域类,它是包裹着NHibernate的包装器类。因此,对其进行序列化可能会导致循环依赖并导致StackOverflowException。但是还有其他一些小问题。例如,有时候我只需要Id和Name 字段可以在JSON中出现,有时候我需要所有的字段。当前的实现不允许我做出决定,它将始终序列化所有字段。
如果看一下ClientService类方法Save,您会发现缺少一些代码。
public void Save(Client item)
{
using(ITransaction tx = this.clientDao.BeginTransaction())
{
/* code that assigns back-references of the child Login objects */
this.ClientDao.Save(item);
tx.Commit();
}
}
这意味着,在保存Client对象之前,您必须设置子Login对象的反向引用。每个Login类都有一个字段——Client,它实际上是对父Client对象的反向引用。因此,为了节约Client与Logins一起使用级联保存,你必须建立这些领域的实际父实例。当Client从JSON反序列化,它不具有反向引用。在NHibernate用户中,这是一个众所周知的问题。
如果查看一下ClientService类方法Update,您将看到也缺少一些代码。
public void Update(Client item)
{
using(ITransaction tx = this.ClientDao.OpenTransaction())
{
Client existing = this.ClientDao.GetById(item.getId());
/* code that copies changes from item to existing */
this.ClientDao.Merge(existing);
}
}
我还必须实现将字段从反序列化的Client对象复制到相同的Client对象的现有持久性实例的逻辑。我的代码必须足够聪明才能通过子级Logins。它必须将现有的登录名与反序列化的登录名匹配,并分别复制字段。它还必须追加新添加的Logins,并删除丢失的内容。完成这些修改后,该Merge()方法将所有更改持久化到数据库。因此,这是相当复杂的逻辑。
在下一节中,我们将使用Jeneva.Net解决这三个问题。
让我们看看Jeneva.Net如何帮助我们解决第一个问题。在ClientController中有两个方法返回Client的对象——GetAll()和GetById()。该GetAll()方法返回Clients的列表,该列表显示为网格。我不需要Client在JSON 中显示对象的所有字段。该GetById()方法在“编辑客户端”页面上使用。因此,此处需要完整的Client信息。
为了解决此问题,我必须遍历返回对象的每个属性,并将NULL值分配给不需要的每个属性。这似乎很艰苦,因为在每种方法上我都必须做不同的事情。Jeneva.Net为我们提供了可以为我们做的Jeneva.Mapper类。让我们使用Mapper类来修改服务层。
public class ClientService
{
public IMapper Mapper { get; set; }
public IClientDao ClientDao { get; set; }
public Client GetById(int clientId)
{
Client item = this.ClientDao.GetById(clientId);
return this.Mapper.Filter(item, Leves.DEEP);
}
public List GetAll()
{
List items = this.ClientDao.getAll();
return this.Mapper.FilterList(items, Levels.GRID);
}
.....
它看起来非常简单,Mapper可以获取目标对象或对象列表并生成它们的副本,但是每个不需要的属性会被设置为NULL。第二个参数是代表序列化级别的数值。Jeneva.Net具有默认级别,但您可以自由定义自己的级别。
public class Levels
{
public const byte ID = 10;
public const byte LOOKUP = 20;
public const byte GRID = 30;
public const byte DEEP = 40;
public const byte FULL = 50;
public const byte NEVER = byte.MaxValue;
}
最后一步是用相应的级别装饰域类的每个属性。我将使用DtoAttribute中的Jeneva.Net装饰Client
和Login类属性。
public class Client : Dtobase
{
[Dto(Levels.ID)]
public virtual int? Id { get; set; }
[Dto(Levels.LOOKUP)]
public virtual string Name { get; set; }
[Dto(Levels.GRID)]
public virtual string Lastname { get; set; }
[Dto(Levels.GRID)]
public virtual int? Age { get; set; }
[Dto(Levels.GRID, Levels.LOOKUP)]
public virtual ISet Logins { get; set; }
}
public class Login : Dtobase
{
[Dto(Levels.ID)]
public virtual int? Id { get; set; }
[Dto(Levels.LOOKUP)]
public virtual string Name { get; set; }
[Dto(Levels.GRID)]
public virtual string Password { get; set; }
[Dto(Levels.GRID)]
public virtual bool? Enabled { get; set; }
[Dto(Levels.NEVER)]
public virtual Client Client { get; set; }
}
装饰完所有属性后,可以使用Mapper类。例如,如果我使用Levels.ID调用Mapper.Filter()方法,那么将仅包含标记为ID的属性。如果我用Levels.LOOKUP调用Mapper.Filter()方法 ,则将包括标记为ID和LOOKUP的属性,因为ID小于LOOKUP(10 <20)。看一下该Client.Logins属性,您将看到在其中应用了两个级别,这意味着什么?这意味着,如果您使用Levels.GRID调用Mapper.Filter()方法,则将包括登录名,但是LOOKUP级别将应用于Login类的属性。而且,如果您调用的Mapper.Filter()级别高于GRID,则应用于Login属性的级别将分别更高。
看一下服务层的类,Save方法。如您所见,此方法接受Client对象。我使用级联保存——我将Client及其Login一起保存。为了做到这一点,子级Login对象必须具有正确分配给父Client对象的反向引用。基本上,我必须遍历子级Logins并将Login.Client属性分配给根Client。完成此操作后,我可以使用NHibernate工具保存Client对象。
除了编写循环之外,我将再次使用Jeneva.Mapper类。让我们修改ClientService类。
public class ClientService
{
public IMapper Mapper { get; set; }
public IClientDao ClientDao { get; set; }
....
public void Save(Client item)
{
using(ITransaction tx = this.ClientDao.BeginTransaction())
{
this.Mapper.Map(item);
this.ClientDao.Save(item);
}
}
该代码将递归地遍历Client 对象的属性并设置所有反向引用。这实际上是解决方案的一半,另一半在此代码中。每个子类都必须实现IChild接口,在该接口中可以告知其父级是谁。该ConnectToParrent()方法将在内部由Mapper类调用。该Mapper会建议基于JSON可能父级。
public class Login : Dtobase, IChild
{
public virtual int? Id { get; set; }
public virtual string Name { get; set; }
public virtual string Password { get; set; }
public virtual bool? Enabled { get; set; }
public virtual Client Client { get; set; }
public void ConnectToParent(Object parent)
{
if(parent is Client)
{
this.Client = parent as Client;
}
}
}
如果正确实现了IChild接口,则只需从服务层调用Map()方法,所有反向引用都将被正确分配。
第三个问题是最复杂的,因为更新客户端是一个复杂的过程。就我而言,我必须更新客户端字段以及更新子级登录名的字段,同时我必须追加,删除子级登录(如果用户已删除或插入新的登录名)。顺便说一句,即使您不使用级联更新,更新任何对象也很复杂。通常,因为要更新对象,总是必须编写自定义代码才能将更改从传入对象复制到现有对象。通常,传入对象仅包含几个要更新的重要字段,其余的是NULL,因此您不能依赖所有字段的盲目复制,因为您不希望将NULL复制到现有数据。
该Mapper类可以将更改从传入对象复制到持久对象,而不覆盖任何重要字段。它是如何工作的?Jeneva.Net带有一个JenevaJsonFormatter类,该类派生自默认情况下ASP.NET MVC 5中使用的Json.Net格式化程序。包含一些小的调整。如您所知,每个领域类都派生自Jeneva.Dtobase抽象类。此类包含属性名称HashSet。当UJenevaJsonFormatter解析JSON,它传递关于解析字段到Dtobase,而Dtobase对象记住哪些字段被分配。因此,每个领域对象都知道在JSON解析期间分配了哪些字段。之后,Mapper 类仅通过传入反序列化对象的分配属性,并将其值复制到现有的持久对象。
这是使用Mapper类的服务层Update()方法:
public class ClientService
{
public IMapper Mapper { get; set; }
public IClientDao ClientDao { get; set; }
....
public void Update(Client item)
{
using(ITransaction tx = this.ClientDao.OpenTransaction())
{
Client existing = this.ClientDao.load(item.getId());
this.Mapper.MapTo(item, existing);
this.ClientDao.Merge(existing);
}
}
}
这是Global.asax.cs,您可以看到如何在Web应用程序中将JenevaJsonFormatter设置为默认格式器。请不要担心从Json.Net格式化程序的切换。如果您看一下Jeneva格式化程序,它是从Json.Net派生而来,仅提供了微小的更改。在Application_Start事件中执行此代码。
GlobalConfiguration.Configuration.Formatters.Remove
(GlobalConfiguration.Configuration.Formatters.JsonFormatter);
GlobalConfiguration.Configuration.Formatters.Add(new JenevaJsonFormatter());
JenevaJsonFormatter还将所有的属性名称转换为Java约定:Name变为name,LoginName变为loginName。这些转换使JSON更具可移植性,并且对JavaScript更友好。它还使您无需进行任何修改即可用Java/Spring替换WebAPI后端。
解决上述问题是Jeneva.Net 可以做到的最大方面。但是,还有另一个有趣的功能可以帮助您实现验证例程——服务器端和客户端。
您可以在本文中找到有关如何使用Jeneva.Net进行验证的更多详细信息:使用Upida.Net/Jeneva.Net验证传入的JSON。
另外,您还可以在我的下一篇文章中找到如何使用AngularJS创建单页Web应用程序(SPA):带有Upida/Jeneva的ASP.NET MVC单页应用程序(前端/AngularJS)。