Java for Web学习笔记(六七):Service和Repository(2)抽象分层例子

小例子说明

Spring提供了很好的架构和很方便的工具,在作为工具使用的同时,也应注意正确使用spring的架构,虽然不是强制的,但是这是spring的精髓。用spring,也要用spring的框架。例如在某次的code review中,看到了在一个Controller中注入另一个controller实例,这种组织方式是凌乱,无法理解一个controller为何是另一个controller的属性,为何有这种从属关系。显然可以通redirect view的方式避免这种情况,又或者将其纳入也作为本UI的一部分。我们需要注意每个UI呈现都应该是独立的,不应该出现交叉。我们要习惯对象编程,不要仅仅为了让代码调通,就随便加入一个参数,关键的衡量在于这个参数是否是这个类或者这个对象的一个属性。如果不是,又需要加入,那么就要考虑你的代码组织或者设计是否存在问题。

小例子模拟论坛,用户可以在上面发布贴(discussion),用户也可以跟帖(reply)。UI界面如下:

数据设计

数据设计很重要,和业务需求密切相关,先看看对贴的需求:

  1. 用户名采用email格式
  2. 帖列表按最后更新(跟帖)时间排序 ➤ 需要维护一个创建时间和一个更新时间
  3. 创建表和跟帖的用户视为订购了这个贴,一旦有更新,需要进一步的操作(例如邮件提示,websocket推送等等)➤ 需要维护要给subscribe的列表
  4. 为了便于搜索引擎搜索,帖子除了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
}

对跟帖的需求比较简单

  1. 是哪楼的跟帖 ➤ 有一个顺序的id号
  2. 需要知道是谁,什么时候的跟帖 ➤ 需要user和创建时间
  3. 和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

小例子很简单,就连个事情:

  1. 贴的管理 --》BoardController,具体而言,显示帖子列表,创建帖子
  2. 跟帖的管理 --》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的接口抽象处理,有下面的好处:

  1. 明确层次边界,当项目越大,越能感受到好处
  2. controller的代码编写可以无需关注service的具体实现
  3. 可以有不同的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只负责数据交互,实现很简单:

  1. 注入service的实例
  2. 根据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接口一样,我们强烈建议提供要给数据读写的接口,即仓库的接口,以便:

  1. 清晰代码的层次接口
  2. 当底层存储介质出现变化,例如内存方式,关系型数据库方式,文件方式,不影响上层的实现;此外如果数据库表格的接口出现变化,例如列的名字变更,接口仍可保持不变
  3. 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相关文章
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值