小例子说明
Spring提供了很好的架构和很方便的工具,在作为工具使用的同时,也应注意正确使用spring的架构,虽然不是强制的,但是这是spring的精髓。用spring,也要用spring的框架。例如在某次的code review中,看到了在一个Controller中注入另一个controller实例,这种组织方式是凌乱,无法理解一个controller为何是另一个controller的属性,为何有这种从属关系。显然可以通redirect view的方式避免这种情况,又或者将其纳入也作为本UI的一部分。我们需要注意每个UI呈现都应该是独立的,不应该出现交叉。我们要习惯对象编程,不要仅仅为了让代码调通,就随便加入一个参数,关键的衡量在于这个参数是否是这个类或者这个对象的一个属性。如果不是,又需要加入,那么就要考虑你的代码组织或者设计是否存在问题。
小例子模拟论坛,用户可以在上面发布贴(discussion),用户也可以跟帖(reply)。UI界面如下:
数据设计
数据设计很重要,和业务需求密切相关,先看看对贴的需求:
- 用户名采用email格式
- 帖列表按最后更新(跟帖)时间排序 ➤ 需要维护一个创建时间和一个更新时间
- 创建表和跟帖的用户视为订购了这个贴,一旦有更新,需要进一步的操作(例如邮件提示,websocket推送等等)➤ 需要维护要给subscribe的列表
- 为了便于搜索引擎搜索,帖子除了id号外,根据subject生成一个uri safe subject,/discussion/{id}/{uriSafeSubject}。➤ 需要uriSafeSubject
更过需求分析,很容易得到Discussion的数据结构
public class Discussion{
private long id;
private String user;
private String subject;
private String uriSafeSubject;
private String message;
private Instant created;
private Instant lastUpdated;
private Set<String> subscribedUsers = new HashSet<>();
// mutators and accessors
}
对跟帖的需求比较简单
- 是哪楼的跟帖 ➤ 有一个顺序的id号
- 需要知道是谁,什么时候的跟帖 ➤ 需要user和创建时间
- 和discussion间的关系,需满足双向检索
- Discussion和Reply有上下级的归属关系 ➤ 通过discussionId,查找到贴下属的reply
- 新增Reply会影响Discussion的更新,包括最后更新时间,订购用户列表。➤ 通过discussionId,查找跟帖归属的discussion
Reply的数据结构:
public class Reply{
private long id;
private long discussionId;
private String user;
private String message;
private Instant created;
// mutators and accessors
}
这些数据结构我们可以统一存放在一个package中,例如cn.wei.chapter14.site.entity,entity是因为他们是我们数据操作的实体。
需要什么样的UI
小例子很简单,就连个事情:
- 贴的管理 --》BoardController,具体而言,显示帖子列表,创建帖子
- 跟帖的管理 --》ReplyController,具体而言,查看帖子(含提交跟帖的form),发布跟帖
Controller要进行什么UI的互动
我们很明确知道UI互动的要求,可以给出下面的Controller代码,暂无具体实现(暂时返回值为null)。@Controller
@RequestMapping("discussion")
public class BoardController{
//显示帖列表
@RequestMapping(value = {"", "list"}, method = RequestMethod.GET)
public String listDiscussions(Map<String, Object> model) { return null; }
//进入创建帖页面
@RequestMapping(value = "create", method = RequestMethod.GET)
public String createDiscussion(Map<String, Object> model) { return null; }
//创建帖
@RequestMapping(value = "create", method = RequestMethod.POST)
public View createDiscussion(DiscussionForm form) { return null; }}
//每个帖子都有一个id号,在路径在携带该id号,进入该贴
//id号位整数,使用正则表达式规范:\\d+ ,由于java String的写法,相当于正则表达式 \d+,\d表示数字,+表示1~N个
@Controller
@RequestMapping("discussion/{discussionId:\\d+}")
public class DiscussionController{
//查看帖子(含提交跟帖的form)
@RequestMapping(value = {"", "*"}, method = RequestMethod.GET)
public String viewDiscussion(Map<String, Object> model,@PathVariable("discussionId") long id) { return null; }
//发布跟帖
@RequestMapping(value = "reply", method = RequestMethod.POST)
public ModelAndView reply(ReplyForm form, @PathVariable("discussionId") long id) { return null; }
}
设计Form的数据结构
一再强调,Form是和用户的UI交互,不等同业务逻辑数据。在业务逻辑中的贴(Discussion)的id,创建时间,更新时间,订购用户列表都是自动设置或更新的,跟帖(Reply)的时间、id、uriSafeSubject也是自动设置和更新的。
public class DiscussionForm {
private String user;
private String subject;
private String message;
... ...
}
public class ReplyForm {
private String user;
private String message;
... ...
}
设计service的接口
抽象接口
我们可以直接提供service的实例,但仍建议进行抽象,如同我们第一个spring的例子。将service的接口抽象处理,有下面的好处:
- 明确层次边界,当项目越大,越能感受到好处
- controller的代码编写可以无需关注service的具体实现
- 可以有不同的service实现,方便controller调测和测试
我们已经设计了数据结构,这是我们处理的实体,在这个例子中,显然不同实体各有一个接口。接口的设计要考虑业务逻辑,我们在之前已经分析了需求,对于帖子:
public interface DiscussionService {
// 显示帖列表
public List<Discussion> getAllDiscussions();
// 在数据分析中,要求根据discussionId实现双向检索,这是discussionId检索Discussion
public Discussion getDisscussion(long discussionId);
// 保持帖子(create和update)。从UI互动来看,似乎用createDiscussion()更为合适,但是业务逻辑的设计不局限于UI,对帖管理,最常规的是增删改查,其中增和改(虽然目前不含此需求)都可以使用此方法。
public void saveDiscussion(Discussion discussion);
}
对于跟帖:
public interface ReplyService {
// 获取帖的全部跟帖
public List<Reply> getRepliesForDiscussion(long discussionId);
// 保存取帖,和discussion一样,不仅仅提供create,还提供update(edit)的功能。
public void saveReply(Reply reply);
}
完成controller
有了接口,我们就可以完成controller。在数据处理中,有自动生成或更新的部分,例如创建时间,用户列表,更新时间,这些应该留在service的具体实现,而不是controller中,controller只负责数据交互,实现很简单:
- 注入service的实例
- 根据service接口实现UI
@Controller
@RequestMapping("discussion")
public class BoardController {
@Inject private DiscussionService discussionService;
@RequestMapping(value = {"","list"}, method = RequestMethod.GET)
public String listDiscussions(Map<String, Object> model){
model.put("discussions", this.discussionService.getAllDiscussions());
return "discussion/list";
}
@RequestMapping(value = "create", method = RequestMethod.GET)
public String createDiscussion(Map<String, Object> model){
model.put("discussionForm", new DiscussionForm());
return "discussion/create";
}
@RequestMapping(value = "create", method = RequestMethod.POST)
public View createDiscussion(DiscussionForm form){
Discussion discussion = new Discussion();
discussion.setUser(form.getUser());
discussion.setSubject(form.getSubject());
discussion.setMessage(form.getMessage());
this.discussionService.saveDiscussion(discussion);
return new RedirectView("/discussion/" + discussion.getId() + "/" + discussion.getUriSafeSubject(),true,false);
}
}
对于跟帖的controller,也类似,controller的代码都不复杂。
@Controller
@RequestMapping("discussion/{discussionId:\\d+}")
public class DiscussionController {
@Inject private DiscussionService discussionService;
@Inject private ReplyService replyService;
@RequestMapping(value = {"", "*"}, method = RequestMethod.GET)
public String viewDiscussion(Map<String, Object> model,@PathVariable("discussionId") long id){
Discussion discussion = this.discussionService.getDisscussion(id);
if(discussion == null)
return "discussion/errorNodiscussion";
model.put("discussion", discussion);
model.put("replies", this.replyService.getRepliesForDiscussion(id));
model.put("replyForm", new ReplyForm());
return "discussion/view";
}
@RequestMapping(value = "reply", method = RequestMethod.POST)
public ModelAndView reply(ReplyForm form,@PathVariable("discussionId") long id){
Discussion discussion = this.discussionService.getDisscussion(id);
if(discussion == null)
return new ModelAndView("discussion/errorNoDiscussion");
Reply reply = new Reply();
reply.setDiscussionId(id);
reply.setUser(form.getUser());
reply.setMessage(form.getMessage());
this.replyService.saveReply(reply);
return new ModelAndView(new RedirectView("/discussion/" + id + "/" + discussion.getUriSafeSubject(),true,false));
}
}
数据处理
从上之下,我们已经完成UI层的代码,业务逻辑层的业务接口,接下来就是继续往下进行数据的具体处理。
Repository接口的设计
虽然Spring没有强制要求,但和service接口一样,我们强烈建议提供要给数据读写的接口,即仓库的接口,以便:
- 清晰代码的层次接口
- 当底层存储介质出现变化,例如内存方式,关系型数据库方式,文件方式,不影响上层的实现;此外如果数据库表格的接口出现变化,例如列的名字变更,接口仍可保持不变
- service层可以基于repository接口编程,无需理会具体的实现,便于调测和测试。
在本例,很明显有两个仓库接口。和service接口设计一样,仓库接口也不限定在当前service的需求,重点的数据的读写,一般而言就是增删改查。虽然小例子目前不需要修改和删除(在写底层的时候,可以比上层当前的需求要放宽),但是仓库接口中,我们也可以一并列出来,因此这是数据读写的接口。
public interface DiscussionRepository {
public List<Discussion> getAll();
public Discussion get(long id);
public void add(Discussion discussion);
public void update(Discussion discussion);
public void delete(long id);
}
public interface ReplyRepository {
public List<Reply> get(long discussionId);
public void add(Reply reply);
public void update(Reply reply);
public void delete(long discussionId);
}
Service的具体实现
有了仓库接口就可以实现Service,也就是在数据读写的基础上,实现我们的业务逻辑。我们先读一下DiscussionService的实现DefaultDiscussionService,有些人习惯使用DiscussionServiceImpl表明是Service的实现,这也是不错的命名方式。同样的,和Controller实现一样,需要仓库实例的注入。
@Service
public class DefaultDiscussionService implements DiscussionService{
//【1】注入所需的仓库实例和Service实例(Service可以调用其它Service)
@Inject private DiscussionRepository discussionRepository;
//【2】具体实现DiscussionService的接口
// 2.1】显示帖子,并根据更新时间排序。这是小例子,在实际中,更倾向于在仓库中实现排序,特别是有数据库存储的时候,且数据量非常的的时候。如果数据量有限,可以在service实现,甚至在controller实现,特别是有几个维度的排序选择的情况。虽然我们进行的逻辑分层,有些内容其实也是具有一定的弹性。
@Override
public List<Discussion> getAllDiscussions() {
List<Discussion> list = this.discussionRepository.getAll();
list.sort((d1,d2) -> d1.getLastUpdated().compareTo(d2.getLastUpdated()));
return list;
}
// 2.2】根据discussionId获取帖子
@Override
public Discussion getDisscussion(long discussionId) {
return this.discussionRepository.get(discussionId);
}
// 2.3】保持帖子(涵盖create和update两种情况),自动设置应在此实现,例如时间设置,注意对于Id的设置我们放置在仓库的具体实现中,id的分配有些是数据库表格自动分配的,又获取我们需要获得当前最大的id号,然后加1,因此这部分属于仓库实现。
@Override
public void saveDiscussion(Discussion discussion) {
// 获得uriSafeSubject,以提供 SEO 友好的 URI 字符串。Normalizer类提供 normalize 方法,它把 Unicode 文本转换为等效的组合或分解形式,允许对文本进行更方便地分类和搜索。Normalizer.Form.NFD是正常化,所有字符摆脱音符 (以便如 é、 ö,à 成为 e,o,a)
String uriSafeSubject = Normalizer.normalize(discussion.getSubject().toLowerCase(), Normalizer.Form.NFD);
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
.replaceAll("[^\\p{Alnum}]+", "-") // 替换所有非字母数字字符由
.replace("--", "-").replace("--", "-")
.replaceAll("[^a-z0-9]+$", "") // 如果全都不是a~z,0~9,设置为空,+表示一或多次,$表示匹配输入字符串的结束位置
.replaceAll("^[^a-z0-9]+", ""); //开始(^)如果不是(a~z,0~9),删除
discussion.setUriSafeSubject(subject);
Instant now = Instant.now();
discussion.setLastUpdated(now);
if(discussion.getId() < 1){ //new:create
discussion.setCreated(now);
discussion.getSubscribedUsers().add(discussion.getUser());
this.discussionRepository.add(discussion);
}else{ //old:update
this.discussionRepository.update(discussion);
}
}
}
仓库的Service试下如下。对于代码编写,如果出现if,else if,else等条件,应该习惯要给出注解
@Service
public class DefaultReplyService implements ReplyService{
//【1】注入所需的仓库实例和Service实例(Service可以调用其它Service)
@Inject
private ReplyRepository replyRepostory;
@Inject
private DiscussionService discussionService;
@Override
public List<Reply> getRepliesForDiscussion(long discussionId) {
List<Reply> list = this.replyRepostory.get(discussionId);
list.sort((r1,r2)->r1.getId() < r2.getId() ? -1 : 1);
return list;
}
@Override
public void saveReply(Reply reply) {
Discussion discussion = this.discussionService.getDisscussion(reply.getDiscussionId());
if(reply.getId() < 1){ //new:user first time reply
discussion.getSubscribedUsers().add(reply.getUser());
reply.setCreated(Instant.now());
this.replyRepostory.add(reply);
}else{ //old: just update his reply
this.replyRepostory.update(reply);
}
this.discussionService.saveDiscussion(discussion);
}
}
仓库的实现
这个小例子的实现,是从高处到底层,从接口到具体,最后,我们需要实现仓库。仓库一般要求数据库,我们还没有学习Hibernate,就先使用内存方式。这里也展示了如何通过接口,采用临时的方式用于调测或测试。
@Repository
public class InMemoryDiscussionRepository implements DiscussionRepository{
private final Map<Long, Discussion> database = new Hashtable<>();
private volatile long discussionIdSequence = 1L;
@Inject
private ReplyRepository replyRepository;
@Override
public List<Discussion> getAll() {
return new ArrayList<>(this.database.values());
}
@Override
public Discussion get(long id) {
return this.database.get(id);
}
@Override
public void add(Discussion discussion) {
discussion.setId(getNextDiscussionId());
this.database.put(discussion.getId(), discussion);
}
@Override
public void update(Discussion discussion) {
this.database.put(discussion.getId(), discussion);
}
@Override
public void delete(long id) {
this.database.remove(id);
this.replyRepository.delete(id);
}
private synchronized long getNextDiscussionId(){
return discussionIdSequence++;
}
}
@Repository
public class InMemoryReplyRepository implements ReplyRepository{
private final Map<Long, Reply> database = new Hashtable<>();
private volatile long replyIdSequence = 1L;
@Override
public List<Reply> get(long discussionId) {
ArrayList<Reply> list = new ArrayList<>(database.values());
list.removeIf(r -> r.getDiscussionId() != discussionId);
return list;
}
//因为hashtable是threadsafe的,当然我们也可以使用ConcurrentHashMap,所以这里没有必要同步
@Override
public /*synchronized*/ void add(Reply reply) {
reply.setId(this.getNextReplyId());
this.database.put(reply.getId(), reply);
}
@Override
public void update(Reply reply) {
this.database.put(reply.getId(), reply);
}
@Override
public void delete(long discussionId) {
this.database.entrySet().removeIf(e -> e.getValue().getDiscussionId() == discussionId);
}
private synchronized long getNextReplyId(){
return replyIdSequence ++;
}
}
小结
小例子中,我们将spring的抽象分层概念落实到实际的代码中,如何分析需求,将需求映射到数据结构,如何从上之下(从高层到底层)逐层编写代码,如何通过接口明确各层之间的边界。
相关链接: 我的Professional Java for Web Applications相关文章