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

使用 Upida/Jeneva(后端)的 Java Spring MVC 单页应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (7投票s)

2014 年 3 月 3 日

CPOL

9分钟阅读

viewsIcon

40333

downloadIcon

495

使用 JSON 进行 Web 开发很简单。

引言

让我们尝试使用最现代的技术创建一个简单的 Web 应用程序,看看我们可能面临哪些问题。我将使用最新的 Spring MVC 和最新的 Hibernate。我将充分利用 Spring Mvc JSON API 的潜力——即浏览器和服务器之间的所有交互都将以 JSON 异步进行。为了实现这一点,我将使用 JavaScript 库 - AngularJS。但是,如果您更喜欢 KnockoutJS 或其他任何东西,这也不是什么大问题。

请注意,本文仅介绍后端。如果您对前端感兴趣,请点击此链接:使用 Upida/Jeneva 的 Java Spring MVC 单页应用程序 (前端/AngularJS)。

假设我们有一个简单的数据库,其中包含两个表:Client Login,每个客户端可以有一个或多个登录。我的应用程序将有三个页面——“客户端列表”、“创建客户端”和“编辑客户端”。“创建客户端”和“编辑客户端”页面将能够编辑客户端数据以及管理子登录列表。这是指向结果 Web 应用程序的链接

首先,让我们定义域(或模型)类(映射在 hbm 文件中定义)

public class Client {

    private Integer id;
    private String name;
    private String lastname;
    private Integer age;
    private Set<Login> logins;

    /* getters and setters go here */
} 

public class Login {

    private Integer id;
    private String name;
    private String password;
    private Boolean enabled;
    private Client client;

    /* getters and setters go here */
}

现在,我可以创建数据访问层。首先,我必须有一个注入了 Hibernate SessionFactory 的基本 DAO(和接口)类,并定义基本的 DAO 操作:SaveDeleteUpdateLoadGet 等。

public interface IDaobase<T> {

    void save(T item);
    void update(T item);
    T merge(T item);
    void delete(T item);
    T get(Serializable id);
    T load(Serializable id);
} 

这是 Daobase

public class Daobase<T> implements IDaobase<T> {

    protected SessionFactory sessionFactory;
 
    public Daobase(SessionFactory sessionFactory) {

        this.sessionFactory = sessionFactory;
    }

    @Override
    public void save(T entity) {

        this.sessionFactory
            .getCurrentSession
            .save(entity);
    }

    @Override
    public void update(T entity) {

        this.sessionFactory
            .getCurrentSession
            .update(entity);
    }
 
    /* others basic methods */
}

我将只有一个 DAO 类——ClientDao

@Repository
public class ClientDao extends Daobase<Client> implements IClientDao {

    @Autowired
    public ClientDao (SessionFactory sessionFactory) {

        super(sessionFactory);
    }

    @Override
    public Client getById(int id) {

        return (Client)this.sessionFactory
            .getCurrentSession()
            .createQuery("from Client client left outer 
                          join fetch client.logins where client.id = :id");
            .setParameter("id", id);
            .setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY)
            .uniqueResult();
    }

    @Override
    public List<Client> GetAll() {

        return this.sessionFactory
            .getCurrentSession()
            .createQuery("from Client client left outer join fetch client.logins");
            .setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);
            .list();
    }
}

当 DAO 完成后,我们可以切换到服务层。服务通常负责打开和关闭事务。我只有一个服务类。它注入了相应的 DAO 类。

注意save()update() 方法接受一个 Client 对象及其子 Logins,因此使用 Hibernate 级联(同时持久化父级和子级)执行保存或更新操作。

@Service
public class ClientService implements IClientService {
    private IClientDao clientDao;
 
    public ClientService(IClientDao clientDao) {

        this.clientDao = clientDao;
    }

    @Override
    @Transactional(readOnly=true)
    public Client getById(int clientId) {

        Client item = this.clientDao.GetById(clientId);
        return item;
    }

    @Override
    @Transactional(readOnly=true)
    public List<Client> getAll() {

        List<Client> items = this.clientDao.getAll();
        return items;
    }

    @Override
    @Transactional
    public void save(Client item) {

        /* TODO: assign back-references of the child Login objects -
           for each Login: item.Login[i].Client = item; */
        this.clientDao.save(item);
    }

    @Override
    @Transactional
    public void update(Client item) {

        Client existing = this.clientDao.load(item.getId());
        /* TODO: copy changes from item to existing (recursively) */
        this.clientDao.merge(existing);
    }
}

让我们谈谈控制器。我将有一个 controller 类,它有两个职责。首先,它将传入的 URL 文本映射到相应的 HTML 视图。其次,它负责处理来自 JavaScript 的 REST 服务调用。下面是它的样子

@Controller
@RequestMapping({"/client"})
public class ClientController {

    private IClientService clientService;

    @Autowired
    public ClientController(IClientService clientService) {

        this.clientService = clientService;
    }

    @RequestMapping(value={"/list"})
    public String list() {

        return "client/list";
    }

    @RequestMapping("/create")
    public String create() {

        return "client/create";
    }

    @RequestMapping("/edit")
    public String edit() {

        return "client/edit";
    }

    @RequestMapping("/getbyid")
    @ResponseBody
    public Client getById(int id) {

        return this.clientService.getById(id);
    }

    @RequestMapping("/getall")
    @ResponseBody
    public List<Client> getAll() {

        return this.clientService.getAll();
    }

    @RequestMapping("/save")
    @ResponseBody
    public void save(@RequestBody Client item) {

        this.clientService.save(item);
    }

    @RequestMapping("/update")
    @ResponseBody
    public void update(@RequestBody Client item) {

        this.clientService.update(item);
    }
}

前三个方法:list()create()edit() 只返回 HTML 视图名称。其他方法更复杂,它们表示暴露给 JavaScript 的 REST 服务。

现在,我们几乎拥有所需的一切。MVC 控制器将为我们提供 HTML 和 JavaScript,它们将与 API 控制器异步交互并从数据库获取数据。AngularJS 将帮助我们将获取的数据显示为漂亮的 HTML。在本文中,我们不讨论 HTML 和 JavaScript。我假设您熟悉 AngularJS(或 KnockoutJS),尽管它在本文中并不那么重要。您唯一需要知道的是——每个页面都作为静态 HTML 和 JavaScript 加载,加载后,它与控制器交互,通过 JSON 异步从数据库加载所有所需的数据片段。AngularJS 有助于将 JSON 显示为漂亮的 HTML。

问题

现在,让我们讨论一下当前实现中面临的问题。

问题 1

第一个问题是序列化。从控制器返回的数据被序列化为 JSON。您可以在这两个控制器方法中看到它。

@Controller
@RequestMapping({"/client"})
public class ClientController {
 ....
    @RequestMapping("/getbyid")
    @ResponseBody
    public Client getById(int id) {

        return this.clientService.getById(id);
    }

    @RequestMapping("/getall")
    @ResponseBody
    public List<Client> getAll() {

        return this.clientService.getAll();
    }

Client 类是一个域类,它用 Hibernate 包装器包装。因此,序列化它可能导致循环依赖并导致 StackOverflowException。但还有其他一些小问题。例如,有时,我只需要在 JSON 中包含 idname 字段,有时我需要所有字段(相同的对象必须以不同方式序列化——包括不同的字段集)。当前的实现不允许我做出这样的决定,它总是会序列化所有字段。

问题 2

如果您查看 ClientService 类,方法 save(),您会发现缺少一些代码。

@Override
@Transactional
public void save(Client item) {

    /* TODO: assign back-references of the child Login objects -
       for each Login: item.Login[i].Client = item; */
    this.clientDao.save(item);
}

这意味着,在保存 Client 对象之前,您必须设置子 Login 对象的反向引用。每个 Login 类都有一个字段 - Client,它实际上是对父 Client 对象的反向引用。因此,为了使用级联保存将 ClientLogins 一起保存,您必须将这些字段设置为实际的父实例。当 Client 从 JSON 反序列化时,它没有反向引用。这是 Hibernate 用户众所周知的问题。

问题 3

如果你看一下 ClientService 类中的 update() 方法,你会发现也缺少一些代码。

@Override
@Transactional
public void update(Client item) {

    Client existing = this.clientDao.load(item.getId());
    /* TODO: copy changes from item to existing (recursively) */
    this.clientDao.merge(existing);
}

我还必须实现逻辑,将字段从反序列化的 Client 对象复制到同一个 Client 的现有持久实例。我的代码必须足够智能才能遍历子 Logins。它必须将现有登录与反序列化的登录匹配,并相应地复制字段。它还必须追加新添加的 Logins 并删除缺失的。在这些修改之后,Merge() 方法将所有更改持久化到数据库。所以这是一个相当复杂的逻辑。

在下一节中,我们将使用 Jeneva 解决这三个问题。

解决方案

问题 1 - 智能序列化

让我们看看 Jeneva 如何帮助我们解决第一个问题。ClientController 有两个返回 Client 对象的方法 - getAll()getById()getAll() 方法返回 Clients 列表,该列表以网格形式显示。我不需要 Client 对象的所有字段都出现在 JSON 中。GetById() 方法用于“编辑客户端”页面。因此,这里需要完整的 Client 信息。

为了解决这个问题,我必须遍历返回对象的每个属性,并为我不需要的每个属性分配 null 值。这看起来相当困难,因为我必须在每个方法中以不同方式执行此操作。Jeneva 为我们提供了 org.jeneva.Mapper 类,它可以为我们完成此操作。让我们使用 Mapper 类修改业务层。

@Service
public class ClientService extends IClientService {

    private IMapper mapper;
    private IClientDao clientDao;

    @Autowired
    public ClientService(IMapper mapper, ClientDao clientDao) {

        this.mapper = mapper;
        this.clientDao = clientDao;
    }

    @Override
    public Client getById(int clientId) {

        Client item = this.clientDao.getById(clientId);
        return this.mapper.filter(item, Leves.DEEP);
    }

    @Override
    public List<Client> getAll() {

        List<Client> items = this.clientDao.getAll();
        return this.mapper.filterList(items, Levels.GRID);
    }
 .....

它看起来非常简单,Mapper 接受目标对象或对象列表,并生成它们的副本,但所有不需要的属性都设置为 null。第二个参数是一个数值,表示序列化级别。Jeneva 不提供默认级别,您必须定义自己的级别。

public class Levels {

    public static final byte ID = 1;
    public static final byte LOOKUP = 2;
    public static final byte GRID = 3;
    public static final byte DEEP = 4;
    public static final byte NEVER = 100;
}

最后一步是用相应的级别装饰我的域类的每个属性。我将使用 Jeneva 中的 DtoAttribute 来装饰 ClientLogin 类属性。

public class Client extends Dtobase {

    /* fields go here */

    @Dto(Levels.ID)
    public Integer getId()           { return this.id; }
 
    @Dto(Levels.LOOKUP)
    public String getName()          { return this.name; }
 
    @Dto(Levels.GRID)
    public String getLastname()      { return this.lastname; }
 
    @Dto(Levels.GRID)
    public Integer getAge()          { return this.age; }
 
    @Dto(Levels.GRID, Levels.LOOKUP)
    public ISet<Login> getLogins()   { return this.logins; }
}

public class Login extends Dtobase {

    /* fields go here */

    @Dto(Levels.ID)
    public Integer getId()           { return this.id; }
 
    @Dto(Levels.LOOKUP)
    public String getName()          { return this.name; }
 
    @Dto(Levels.GRID)
    public String getPassword()      { return this.password; }
 
    @Dto(Levels.GRID)
    public Boolean getEnabled()      { return this.enabled; }
 
    @Dto(Levels.NEVER)
    public Client getClient()        { return this.client; }
}

所有属性都装饰好后,我可以使用 Mapper 类。例如,如果我使用 Levels.ID 调用 Mapper.filter() 方法,那么只有标记为 ID 的属性才会被包含。如果我使用 Levels.LOOKUP 调用 Mapper.filter() 方法,那么标记为 IDLOOKUP 的属性都将被包含,因为 ID 小于 LOOKUP (10 < 20)。看看 Client.logins 属性,正如你所看到的,那里应用了两个级别,这意味着什么?这意味着如果你使用 Levels.GRID 调用 Mapper.filter() 方法,那么登录将被包含,但 LOOKUP 级别将应用于 Login 类的属性。如果你的调用 Mapper.filter() 方法的级别高于 GRID,那么应用于 Login 属性的级别将相应地变高。

问题 2 - 反向引用

看一下业务层类,save() 方法。正如你所看到的,这个方法接受 Client 对象。我使用级联保存——我同时保存 Client 及其 Login。为了实现这一点,子 Login 对象必须正确地将反向引用分配给父 Client 对象。基本上,我必须遍历子 Logins 并将 Login.client 属性分配给根 Client。完成此操作后,我可以使用 Hibernate 工具保存 Client 对象。

我将再次使用 org.jeneva.Mapper 类,而不是编写循环。让我们修改 ClientService 类。

@Service
public class ClientService implements IClientService {

    private IMapper mapper;
    private IClientDao clientDao;

    @Autowired
    public ClientService(IMapper mapper, ClientDao clientDao) {

        this.mapper = mapper;
        this.clientDao = clientDao;
    }
 ....
    @Override
    public void save(Client item) {

        this.mapper.map(item);
        this.clientDao.save(item);
    }

此代码将递归遍历 Client 对象的属性并设置所有反向引用。这实际上是解决方案的一半,另一半在此代码中。每个子类都必须实现 IChild 接口,它可以在其中说明其父级是谁。connectToParent() 方法将由 Mapper 类在内部调用。Mapper 将根据 JSON 建议可能的父级。

public class Login extends Dtobase implements IChild {

    private Integer id;
    private String name;
    private String password;
    private Boolean enabled;
    private Client client;

    /* getters and setters go here */
 
    public void connectToParent(Object parent) {

        if(parent instanceof Client) {
            this.Client = (Client)parent;
        }
    }
}

如果 IChild 接口实现正确,您只需从业务层调用 Map() 方法,所有反向引用都将正确分配。

问题 3 - 映射更新

第三个问题最复杂,因为更新客户端是一个复杂的过程。在我的情况下,我必须更新客户端字段以及更新子登录的字段,同时,如果用户删除或插入了新的登录,我必须追加、删除子登录。顺便说一句,更新任何对象,即使您不使用级联更新,也很复杂。主要是因为当您想要更新一个对象时,您总是必须编写自定义代码来将传入对象的更改复制到现有对象。通常,传入对象只包含几个重要的字段以供更新,其余的都是 null,因此您不能盲目复制所有字段,因为您不希望将 null 复制到现有数据。

Mapper 类可以将更改从传入对象复制到持久对象,而不会覆盖任何重要字段。它是如何工作的?Jeneva 附带了一个 JenevaJsonConverter 类,它继承自 Spring MVC 默认使用的 MappingJacksonHttpMessageConverterJenevaJsonConverter 包含一些微小的调整。如您所知,每个域类都派生自 org.jeneva.Dtobase 抽象类。该类包含属性名称的 HashSet。当 JenevaJsonConverter 解析 JSON 时,它将有关解析字段的信息传递给 Dtobase,并且 Dtobase 对象会记住哪些字段已分配。因此,每个域对象都知道在 JSON 解析期间哪些字段已分配。稍后,Mapper 类将只遍历传入反序列化对象的已分配属性,并将其值复制到现有持久对象。

这是使用 Mapper 类的业务层 update() 方法

@Service
public class ClientService {

    private IMapper mapper;
    private IClientDao clientDao;

    @Autowired
    public ClientService(IMapper mapper, ClientDao clientDao) {

        this.mapper = mapper;
        this.clientDao = clientDao;
    }
 ....
    @Override
    public void update(Client item) {

        Client existing = this.clientDao.load(item.getId());
        this.mapper.mapTo(item, existing, Client.class);
        this.clientDao.merge(existing);
    }
}

这是 Spring bean 文件的一部分。您可以看到如何将 JenevaJsonConverter 设置为您的 Web 应用程序中的默认转换器。请不要担心从 Spring 默认转换器切换。如果您查看 Jeneva 转换器,它派生自 Jackson 转换器,并且只提供了一些细微的更改。

<mvc:annotation-driven>
    <mvc:message-converters>
        <bean class="org.jeneva.spring.JenevaJsonConverter" />
   </mvc:message-converters>
</mvc:annotation-driven>

注释

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

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

此外,您还可以在我的下一篇文章中了解如何使用 AngularJS 创建单页 Web 应用程序 (SPA):AngularJS 单页应用程序和 Upida/Jeneva

参考文献

© . All rights reserved.