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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (21投票s)

2013 年 10 月 1 日

CPOL

10分钟阅读

viewsIcon

77530

downloadIcon

762

使用 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 类,它注入了 NHibernateSessionFactory,并定义了基本的 DAO 操作:SaveDeleteUpdateLoadGet 等。

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 类。

注意SaveUpdate 方法接受一个 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 中包含 IdName 字段,有时我需要所有字段。当前的实现不允许我做出这个决定,它将始终序列化所有字段。

问题 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 对象的反向引用。因此,为了使用级联保存将 ClientLogins 一起保存,您必须将这些字段设置为实际的父实例。当 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 来装饰 ClientLogin 类的属性。

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,那么标记为 IDLOOKUP 的属性都会被包含,因为 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 变为 nameLoginName 变为 loginName。这些转换使 JSON 更具可移植性,更适合 JavaScript。它还使您无需任何修改即可将 WebAPI 后端替换为 Java/Spring

注释

解决上述问题是 Jeneva.Net 最重要的功能。然而,还有一个有趣的功能可以帮助您实现验证例程——包括服务器端和客户端。

您可以在本文中找到有关如何使用 Jeneva.Net 实现验证的更多详细信息:使用 Upida.Net/Jeneva.Net 验证传入的 JSON。

此外,您还可以在我的下一篇文章中找到如何使用 AngularJS 创建单页 Web 应用程序 (SPA) 的方法:使用 Upida/Jeneva (前端/AngularJS) 的 ASP.NET MVC 单页应用

参考文献

历史

  • 2013年10月1日:初始版本
© . All rights reserved.