使用 Upida/Jeneva.Net (后端) 的 ASP.NET MVC 单页应用






4.85/5 (21投票s)
使用 Jeneva.Net 进行 Web 开发中的 MVC/SPA/REST
引言
让我们尝试使用最现代的技术创建一个简单的 Web 应用程序,并看看可能面临哪些问题。我将使用最新的 ASP.NET MVC 结合 WebAPI 和最新的 NHibernate。请不要担心,所有技术也适用于 Entity Framework(可下载的 ZIP 存档中也包含 Entity Framework 示例)。我将充分利用 WebAPI——也就是说,浏览器和服务器之间的所有交互都将以 JSON 异步进行。为了实现这一点,我将使用 MVC JavaScript 库——AngularJS。
请注意,本文仅关注后端。如果您对前端感兴趣,请点击此链接:使用 Upida/Jeneva (前端/AngularJS) 的 ASP.NET MVC 单页应用。
让我们假设我们有一个简单的数据库,包含两个表: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<Login> 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<T> : 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<Client>, 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<int>("id", id)
.SetResultTransformer(Transformers.DistinctRootEntity)
.UniqueResult<client>();
}
public IList<client> GetAll()
{
return this.SessionFactory
.GetCurrentSession()
.CreateQuery("from Client client left outer join fetch client.Logins")
.SetResultTransformer(Transformers.DistinctRootEntity)
.List<client>();
}
}
完成 DAO 后,我们可以切换到服务层。服务通常负责打开和关闭事务。我只有一个服务类。它注入了相应的 DAO 类。
注意,Save
和 Update
方法接受一个 Client
对象及其子 Login,因此使用 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<Client> GetAll()
{
List<Client> 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<Client> 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 加载(不含任何服务器端脚本),加载后,它通过 JSON 异步与 API 控制器交互,从数据库加载所有所需的数据片段。而 AngularJS 帮助将这些 JSON 显示为精美的 HTML。
问题
现在,让我们谈谈在当前实现中面临的问题。
问题 1
第一个问题是序列化。从 API 控制器返回的数据被序列化为 JSON。您可以在这两个 API 控制器方法中看到它
public class ClientController : System.Web.Http.ApiController
{ ....
public IList<Client> GetAll()
{
return this.ClientService.GetAll();
}
public Client GetById(int id)
{
return this.ClientService.GetById(id);
}
Client
类是一个领域类,它被 NHibernate 包装器包裹。因此,对其进行序列化可能会导致循环依赖并引发 StackOverflowException
。但还有其他一些次要问题。例如,有时我只需要在 JSON 中包含 Id
和 Name
字段,有时我需要所有字段。当前的实现不允许我做出这个决定,它将始终序列化所有字段。
问题 2
如果您查看 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 用户中一个众所周知的问题。
问题 3
如果您查看 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 解决这三个问题。
解决方案
问题 1 - 智能序列化
让我们看看 Jeneva.Net 如何帮助我们解决第一个问题。ClientController
有两个方法返回 Client
对象——GetAll()
和 GetById()
。GetAll()
方法返回 Clients
列表,该列表以网格形式显示。我不需要在 JSON 中显示 Client
对象的所有字段。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<Client> GetAll()
{
List<Client> 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;
}
最后一步是使用相应的级别来装饰我的领域类的每个属性。我将使用 Jeneva.Net 中的 DtoAttribute
来装饰 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<Login> 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
类。例如,如果我调用 Mapper.Filter()
方法并传入 Levels.ID
,那么只有标记为 ID
的属性会被包含。如果我调用 Mapper.Filter()
方法并传入 Levels.LOOKUP
,那么标记为 ID
和 LOOKUP
的属性都会被包含,因为 ID
小于 LOOKUP
(10 < 20)。请看 Client.Logins
属性,如您所见,那里应用了两个级别,这意味着什么?这意味着如果您调用 Mapper.Filter()
方法并传入 Levels.GRID
,那么 logins 会被包含,但 LOOKUP
级别会应用到 Login
类的属性。如果您调用 Mapper.Filter()
方法并传入高于 GRID
的级别,那么应用到 Login
属性的级别将相应提高。
问题 2 - 反向引用
查看服务层类,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
接口,它可以在其中说明其父级是谁。ConnectToParent()
方法将由 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()
方法,所有反向引用都将正确分配。
问题 3 - 映射更新
第三个问题是最复杂的,因为更新客户端是一个复杂的过程。在我的案例中,我必须更新客户端字段以及子登录的字段,同时,如果用户删除或插入了新的登录,我还必须追加、删除子登录。顺便说一句,更新任何对象,即使您不使用级联更新,也是复杂的。主要是因为当您想要更新一个对象时,您总是必须编写自定义代码以将传入对象中的更改复制到现有对象。通常,传入对象只包含几个重要的字段以进行更新,其余字段都是 NULL
,因此您不能盲目地复制所有字段,因为您不希望将 NULL
复制到现有数据中。
Mapper
类可以将传入对象中的更改复制到持久化对象,而不会覆盖任何重要字段。它是如何工作的?Jeneva.Net 带有 JenevaJsonFormatter
类,它派生自 ASP.NET MVC 5 默认使用的 Json.Net 格式化程序。JenevaJsonFormatter
包含一些微小的调整。如您所知,每个域类都派生自 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,您可以看到如何将 JenevaJsonFormatter
设置为 Web 应用程序的默认格式化程序。请不要担心从 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。它还使您无需任何修改即可将 WebAPI 后端替换为 Java/Spring。
注释
解决上述问题是 Jeneva.Net 最重要的功能。然而,还有一个有趣的功能可以帮助您实现验证例程——包括服务器端和客户端。
您可以在本文中找到有关如何使用 Jeneva.Net 实现验证的更多详细信息:使用 Upida.Net/Jeneva.Net 验证传入的 JSON。
此外,您还可以在我的下一篇文章中找到如何使用 AngularJS 创建单页 Web 应用程序 (SPA) 的方法:使用 Upida/Jeneva (前端/AngularJS) 的 ASP.NET MVC 单页应用。
参考文献
- 在 codeplex 下载最新版本
- 查看工作示例
- 文章:AngularJS 单页应用与 Upida.Net/Jeneva.Net
- 文章:使用 Upida.Net/Jeneva.Net 验证传入的 JSON
- Codeplex 上的 Jeneva Java 版本
历史
- 2013年10月1日:初始版本