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






4.80/5 (7投票s)
使用 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 操作:Save
、Delete
、Update
、Load
、Get
等。
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 中包含 id
和 name
字段,有时我需要所有字段(相同的对象必须以不同方式序列化——包括不同的字段集)。当前的实现不允许我做出这样的决定,它总是会序列化所有字段。
问题 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
对象的反向引用。因此,为了使用级联保存将 Client
与 Logins
一起保存,您必须将这些字段设置为实际的父实例。当 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
来装饰 Client
和 Login
类属性。
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()
方法,那么标记为 ID
和 LOOKUP
的属性都将被包含,因为 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 默认使用的 MappingJacksonHttpMessageConverter
。JenevaJsonConverter
包含一些微小的调整。如您所知,每个域类都派生自 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。