NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

那些年,我们见过的 Java 服务端乱象

  • 2019-08-12
  • 本文字数:8861 字

    阅读完需:约 29 分钟

那些年,我们见过的 Java 服务端乱象

导读

查尔斯·狄更斯在《双城记》中写道:“这是一个最好的时代,也是一个最坏的时代。”


移动互联网的快速发展,出现了许多新机遇,很多创业者伺机而动;随着行业竞争加剧,互联网红利逐渐消失,很多创业公司九死一生。笔者在初创公司摸爬滚打数年,接触了各式各样的 Java 微服务架构,从中获得了一些优秀的理念,但也发现了一些不合理的现象。现在,笔者总结了一些创业公司存在的 Java 服务端乱象,并尝试性地给出了一些不成熟的建议。

1.使用 Controller 基类和 Service 基类

1.1.现象描述

1.1.1.Controller 基类

常见的 Controller 基类如下:


/** 基础控制器类 */public class BaseController {    /** 注入服务相关 */    /** 用户服务 */    @Autowired    protected UserService userService;    ...
/** 静态常量相关 */ /** 手机号模式 */ protected static final String PHONE_PATTERN = "/^[]([3-9])[0-9]{9}$/"; ...
/** 静态函数相关 */ /** 验证电话 */ protected static vaildPhone(String phone) {...} ...}
复制代码


常见的 Controller 基类主要包含注入服务、静态常量和静态函数等,便于所有的 Controller 继承它,并在函数中可以直接使用这些资源。

1.1.2.Service 基类

常见的 Service 基类如下:


/** 基础服务类 */public class BaseService {    /** 注入DAO相关 */    /** 用户DAO */    @Autowired    protected UserDAO userDAO;    ...
/** 注入服务相关 */ /** 短信服务 */ @Autowired protected SmsService smsService; ... /** 注入参数相关 */ /** 系统名称 */ @Value("${example.systemName}") protected String systemName; ...
/** 静态常量相关 */ /** 超级用户标识 */ protected static final long SUPPER_USER_ID = 0L; ...
/** 服务函数相关 */ /** 获取用户函数 */ protected UserDO getUser(Long userId) {...} ...
/** 静态函数相关 */ /** 获取用户名称 */ protected static String getUserName(UserDO user) {...} ...}
复制代码


常见的 Service 基类主要包括注入 DAO、注入服务、注入参数、静态常量、服务函数、静态函数等,便于所有的 Service 继承它,并在函数中可以直接使用这些资源。

1.2.论证基类必要性

首先,了解一下里氏替换原则:


里氏代换原则(LiskovSubstitutionPrinciple,简称 LSP):所有引用基类(父类)的地方必须能透明地使用其子类的对象。


其次,了解一下基类的优点:


  • 子类拥有父类的所有方法和属性,从而减少了创建子类的工作量;

  • 提高了代码的重用性,子类拥有父类的所有功能;

  • 提高了代码的扩展性,子类可以添加自己的功能。


所以,我们可以得出以下结论:


  • Controller 基类和 Service 基类在整个项目中并没有直接被使用,也就没有可使用其子类替换基类的场景,所以不满足里氏替换原则;

  • Controller 基类和 Service 基类并没有抽象接口函数或虚函数,即所有继承基类的子类间没有相关共性,直接导致在项目中仍然使用的是子类;

  • Controller 基类和 Service 基类只关注了重用性,即子类能够轻松使用基类的注入 DAO、注入服务、注入参数、静态常量、服务函数、静态函数等资源。但是,忽略了这些资源的必要性,即这些资源并不是子类所必须的,反而给子类带来了加载时的性能损耗。


综上所述,Controller 基类和 Service 基类只是一个杂凑类,并不是一个真正意义上的基类,需要进行拆分。

1.3.拆分基类的方法

由于 Service 基类比 Controller 基类更典型,本文以 Service 基类举例说明如何来拆分“基类”。

1.3.1.把注入实例放入实现类

根据“使用即引入、无用则删除”原则,在需要使用的实现类中注入需要使用的 DAO、服务和参数。


/** 用户服务类 */@Servicepublic class UserService {    /** 用户DAO */    @Autowired    private UserDAO userDAO;
/** 短信服务 */ @Autowired private SmsService smsService;
/** 系统名称 */ @Value("${example.systemName}") private String systemName; ...}
复制代码

1.3.2.把静态常量放入常量类

对于静态常量,可以把它们封装到对应的常量类中,在需要时直接使用即可。


/** 例子常量类 */public class ExampleConstants {    /** 超级用户标识 */    public static final long SUPPER_USER_ID = 0L;    ...}
复制代码

1.3.3.把服务函数放入服务类

对于服务函数,可以把它们封装到对应的服务类中。在别的服务类使用时,可以注入该服务类实例,然后通过实例调用服务函数。


/** 用户服务类 */@Servicepublic class UserService {    /** 获取用户函数 */    public UserDO getUser(Long userId) {...}    ...}
/** 公司服务类 */@Servicepublic class CompanyService { /** 用户服务 */ @Autowired private UserService userService; /** 获取管理员 */ public UserDO getManager(Long companyId) { CompanyDO company = ...; return userService.getUser(company.getManagerId()); } ...}
复制代码

1.3.4.把静态函数放入工具类

对于静态函数,可以把它们封装到对应的工具类中,在需要时直接使用即可。


/** 用户辅助类 */public class UserHelper {    /** 获取用户名称 */    public static String getUserName(UserDO user) {...}    ...}
复制代码

2. 把业务代码写在 Controller 中

2.1.现象描述

我们会经常会在 Controller 类中看到这样的代码:


/** 用户控制器类 */@Controller@RequestMapping("/user")public class UserController {    /** 用户DAO */    @Autowired    private UserDAO userDAO;
/** 获取用户函数 */ @ResponseBody @RequestMapping(path = "/getUser", method = RequestMethod.GET) public Result<UserVO> getUser(@RequestParam(name = "userId", required = true) Long userId) { // 获取用户信息 UserDO userDO = userDAO.getUser(userId); if (Objects.isNull(userDO)) { return null; } // 拷贝并返回用户 UserVO userVO = new UserVO(); BeanUtils.copyProperties(userDO, userVO); return Result.success(userVO); } ...}
复制代码


编写人员给出的理由是:一个简单的接口函数,这么写也能满足需求,没有必要去封装成一个服务函数。

2.2.一个特殊的案例

案例代码如下:


/** 测试控制器类 */@Controller@RequestMapping("/test")public class TestController {    /** 系统名称 */    @Value("${example.systemName}")    private String systemName;        /** 访问函数 */    @RequestMapping(path = "/access", method = RequestMethod.GET)    public String access() {        return String.format("系统(%s)欢迎您访问!", systemName);    }}
复制代码


访问结果如下:


curl http://localhost:8080/test/access系统(null)欢迎您访问!
复制代码


为什么参数 systemName(系统名称)没有被注入值?《SpringDocumentation》给出的解释是:


Notethatactualprocessingofthe@ValueannotationisperformedbyaBeanPostProcessor.

BeanPostProcessorinterfacesarescopedper-container.Thisisonlyrelevantifyouareusingcontainerhierarchies.IfyoudefineaBeanPostProcessorinonecontainer,itwillonlydoitsworkonthebeansinthatcontainer.Beansthataredefinedinonecontainerarenotpost-processedbyaBeanPostProcessorinanothercontainer,evenifbothcontainersarepartofthesamehierarchy.


意思是说:@Value 是通过 BeanPostProcessor 来处理的,而 WebApplicationContex 和 ApplicationContext 是单独处理的,所以 WebApplicationContex 不能使用父容器的属性值。


所以,Controller 不满足 Service 的需求,不要把业务代码写在 Controller 类中。

2.3.服务端三层架构

SpringMVC 服务端采用经典的三层架构,即表现层、业务层、持久层,分别采用 @Controller、@Service、@Repository 进行类注解。



表现层(Presentation):又称控制层(Controller),负责接收客户端请求,并向客户端响应结果,通常采用 HTTP 协议。


业务层(Business):又称服务层(Service),负责业务相关逻辑处理,按照功能分为服务、作业等。


持久层(Persistence):又称仓库层(Repository),负责数据的持久化,用于业务层访问缓存和数据库。


所以,把业务代码写入到 Controller 类中,是不符合 SpringMVC 服务端三层架构规范的。

3.把持久层代码写在 Service 中

把持久层代码写在 Service 中,从功能上来看并没有什么问题,这也是很多人欣然接受的原因。

3.1.引起以下主要问题

  • 业务层和持久层混杂在一起,不符合 SpringMVC 服务端三层架构规范;

  • 在业务逻辑中组装语句、主键等,增加了业务逻辑的复杂度;

  • 在业务逻辑中直接使用第三方中间件,不便于第三方持久化中间件的替换;

  • 同一对象的持久层代码分散在各个业务逻辑中,背离了面对对象的编程思想;

  • 在写单元测试用例时,无法对持久层接口函数直接测试。

3.2.把数据库代码写在 Service 中

这里以数据库持久化中间件 Hibernate 的直接查询为例。

现象描述:

/** 用户服务类 */@Servicepublic class UserService {    /** 会话工厂 */    @Autowired    private SessionFactory sessionFactory;
/** 根据工号获取用户函数 */ public UserVO getUserByEmpId(String empId) { // 组装HQL语句 String hql = "from t_user where emp_id = '" + empId + "'"; // 执行数据库查询 Query query = sessionFactory.getCurrentSession().createQuery(hql); List<UserDO> userList = query.list(); if (CollectionUtils.isEmpty(userList)) { return null; } // 转化并返回用户 UserVO userVO = new UserVO(); BeanUtils.copyProperties(userList.get(0), userVO); return userVO; }}
复制代码

建议方案:

/** 用户DAO类 */@Repositorypublic class UserDAO {     /** 会话工厂 */    @Autowired    private SessionFactory sessionFactory;        /** 根据工号获取用户函数 */    public UserDO getUserByEmpId(String empId) {        // 组装HQL语句        String hql = "from t_user where emp_id = '" + empId + "'";                // 执行数据库查询        Query query = sessionFactory.getCurrentSession().createQuery(hql);        List<UserDO> userList = query.list();        if (CollectionUtils.isEmpty(userList)) {            return null;        }                // 返回用户信息        return userList.get(0);    }}
/** 用户服务类 */@Servicepublic class UserService { /** 用户DAO */ @Autowired private UserDAO userDAO;
/** 根据工号获取用户函数 */ public UserVO getUserByEmpId(String empId) { // 根据工号查询用户 UserDO userDO = userDAO.getUserByEmpId(empId); if (Objects.isNull(userDO)) { return null; } // 转化并返回用户 UserVO userVO = new UserVO(); BeanUtils.copyProperties(userDO, userVO); return userVO; }}
复制代码

关于插件:

阿里的 AliGenerator 是一款基于 MyBatisGenerator 改造的 DAO 层代码自动生成工具。利用 AliGenerator 生成的代码,在执行复杂查询的时候,需要在业务代码中组装查询条件,使业务代码显得特别臃肿。


/** 用户服务类 */@Servicepublic class UserService {    /** 用户DAO */    @Autowired    private UserDAO userDAO;
/** 获取用户函数 */ public UserVO getUser(String companyId, String empId) { // 查询数据库 UserParam userParam = new UserParam(); userParam.createCriteria().andCompanyIdEqualTo(companyId) .andEmpIdEqualTo(empId) .andStatusEqualTo(UserStatus.ENABLE.getValue()); List<UserDO> userList = userDAO.selectByParam(userParam); if (CollectionUtils.isEmpty(userList)) { return null; } // 转化并返回用户 UserVO userVO = new UserVO(); BeanUtils.copyProperties(userList.get(0), userVO); return userVO; }}
复制代码


个人不喜欢用 DAO 层代码生成插件,更喜欢用原汁原味的 MyBatisXML 映射,主要原因如下:


  • 会在项目中导入一些不符合规范的代码;

  • 只需要进行一个简单查询,也需要导入一整套复杂代码;

  • 进行复杂查询时,拼装条件的代码复杂且不直观,不如在 XML 中直接编写 SQL 语句;

  • 变更表格后需要重新生成代码并进行覆盖,可能会不小心删除自定义函数。


当然,既然选择了使用 DAO 层代码生成插件,在享受便利的同时也应该接受插件的缺点。

3.3.把 Redis 代码写在 Service 中

现象描述:

/** 用户服务类 */@Servicepublic class UserService {    /** 用户DAO */    @Autowired    private UserDAO userDAO;    /** Redis模板 */    @Autowired    private RedisTemplate<String, String> redisTemplate;    /** 用户主键模式 */    private static final String USER_KEY_PATTERN = "hash::user::%s";
/** 保存用户函数 */ public void saveUser(UserVO user) { // 转化用户信息 UserDO userDO = transUser(user);
// 保存Redis用户 String userKey = MessageFormat.format(USER_KEY_PATTERN, userDO.getId()); Map<String, String> fieldMap = new HashMap<>(8); fieldMap.put(UserDO.CONST_NAME, user.getName()); fieldMap.put(UserDO.CONST_SEX, String.valueOf(user.getSex())); fieldMap.put(UserDO.CONST_AGE, String.valueOf(user.getAge())); redisTemplate.opsForHash().putAll(userKey, fieldMap);
// 保存数据库用户 userDAO.save(userDO); }}
复制代码

建议方案:

/** 用户Redis类 */@Repositorypublic class UserRedis {    /** Redis模板 */    @Autowired    private RedisTemplate<String, String> redisTemplate;    /** 主键模式 */    private static final String KEY_PATTERN = "hash::user::%s";        /** 保存用户函数 */    public UserDO save(UserDO user) {        String key = MessageFormat.format(KEY_PATTERN, userDO.getId());        Map<String, String> fieldMap = new HashMap<>(8);        fieldMap.put(UserDO.CONST_NAME, user.getName());        fieldMap.put(UserDO.CONST_SEX, String.valueOf(user.getSex()));        fieldMap.put(UserDO.CONST_AGE, String.valueOf(user.getAge()));        redisTemplate.opsForHash().putAll(key, fieldMap);    }}
/** 用户服务类 */@Servicepublic class UserService { /** 用户DAO */ @Autowired private UserDAO userDAO; /** 用户Redis */ @Autowired private UserRedis userRedis;
/** 保存用户函数 */ public void saveUser(UserVO user) { // 转化用户信息 UserDO userDO = transUser(user);
// 保存Redis用户 userRedis.save(userDO);
// 保存数据库用户 userDAO.save(userDO); }}
复制代码


把一个 Redis 对象相关操作接口封装为一个 DAO 类,符合面对对象的编程思想,也符合 SpringMVC 服务端三层架构规范,更便于代码的管理和维护。

4.把数据库模型类暴露给接口

4.1.现象描述

/** 用户DAO类 */@Repositorypublic class UserDAO {    /** 获取用户函数 */    public UserDO getUser(Long userId) {...}}
/** 用户服务类 */@Servicepublic class UserService { /** 用户DAO */ @Autowired private UserDAO userDAO;
/** 获取用户函数 */ public UserDO getUser(Long userId) { return userDAO.getUser(userId); }}
/** 用户控制器类 */@Controller@RequestMapping("/user")public class UserController { /** 用户服务 */ @Autowired private UserService userService;
/** 获取用户函数 */ @RequestMapping(path = "/getUser", method = RequestMethod.GET) public Result<UserDO> getUser(@RequestParam(name = "userId", required = true) Long userId) { UserDO user = userService.getUser(userId); return Result.success(user); }}
复制代码


上面的代码,看上去是满足 SpringMVC 服务端三层架构的,唯一的问题就是把数据库模型类 UserDO 直接暴露给了外部接口。

4.2.存在问题及解决方案

存在问题


  • 间接暴露数据库表格设计,给竞争对手竞品分析带来方便;

  • 如果数据库查询不做字段限制,会导致接口数据庞大,浪费用户的宝贵流量;

  • 如果数据库查询不做字段限制,容易把敏感字段暴露给接口,导致出现数据的安全问题;

  • 如果数据库模型类不能满足接口需求,需要在数据库模型类中添加别的字段,导致数据库模型类跟数据库字段不匹配问题;

  • 如果没有维护好接口文档,通过阅读代码是无法分辨出数据库模型类中哪些字段是接口使用的,导致代码的可维护性变差。


解决方案


  • 从管理制度上要求数据库和接口的模型类完全独立;

  • 从项目结构上限制开发人员把数据库模型类暴露给接口。

4.3.项目搭建的三种方式

下面,将介绍如何更科学地搭建 Java 项目,有效地限制开发人员把数据库模型类暴露给接口。

第 1 种:共用模型的项目搭建

共用模型的项目搭建,把所有模型类放在一个模型项目(example-model)中,其它项目(example-repository、example-service、example-website)都依赖该模型项目,关系图如下:




风险:表现层项目(example-webapp)可以调用业务层项目(example-service)中的任意服务函数,甚至于越过业务层直接调用持久层项目(example-repository)的 DAO 函数。

第 2 种:模型分离的项目搭建

模型分离的项目搭建,单独搭建 API 项目(example-api),抽象出对外接口及其模型 VO 类。业务层项目(example-service)实现了这些接口,并向表现层项目(example-webapp)提供服务。表现层项目(example-webapp)只调用 API 项目(example-api)定义的服务接口。




风险:表现层项目(example-webapp)仍然可以调用业务层项目(example-service)提供的内部服务函数和持久层项目(example-repository)的 DAO 函数。为了避免这种情况,只好管理制度上要求表现层项目(example-webapp)只能调用 API 项目(example-api)定义的服务接口函数。

第 3 种:服务化的项目搭建

服务化的项目搭,就是把业务层项目(example-service)和持久层项目(example-repository)通过 Dubbo 项目(example-dubbo)打包成一个服务,向业务层项目(example-webapp)或其它业务项目(other-service)提供 API 项目(example-api)中定义的接口函数。




说明:Dubbo 项目(example-dubbo)只发布 API 项目(example-api)中定义的服务接口,保证了数据库模型无法暴露。业务层项目(example-webapp)或其它业务项目(other-service)只依赖了 API 项目(example-api),只能调用该项目中定义的服务接口。

4.4.一条不太建议的建议

有人会问:接口模型和持久层模型分离,接口定义了一个查询数据模型 VO 类,持久层也需要定义一个查询数据模型 DO 类;接口定义了一个返回数据模型 VO 类,持久层也需要定义一个返回数据模型 DO 类……这样,对于项目早期快速迭代开发非常不利。能不能只让接口不暴露持久层数据模型,而能够让持久层使用接口的数据模型?


如果从 SpringMVC 服务端三层架构来说,这是不允许的,因为它会影响三层架构的独立性。但是,如果从快速迭代开发来说,这是允许的,因为它并不会暴露数据库模型类。所以,这是一条不太建议的建议。


/** 用户DAO类 */@Repositorypublic class UserDAO {    /** 统计用户函数 */    public Long countByParameter(QueryUserParameterVO parameter) {...}    /** 查询用户函数 */    public List<UserVO> queryByParameter(QueryUserParameterVO parameter) {...}}
/** 用户服务类 */@Servicepublic class UserService { /** 用户DAO */ @Autowired private UserDAO userDAO;
/** 查询用户函数 */ public PageData<UserVO> queryUser(QueryUserParameterVO parameter) { Long totalCount = userDAO.countByParameter(parameter); List<UserVO> userList = null; if (Objects.nonNull(totalCount) && totalCount.compareTo(0L) > 0) { userList = userDAO.queryByParameter(parameter); } return new PageData<>(totalCount, userList); }}
/** 用户控制器类 */@Controller@RequestMapping("/user")public class UserController { /** 用户服务 */ @Autowired private UserService userService;
/** 查询用户函数(parameter中包括分页参数startIndex和pageSize) */ @RequestMapping(path = "/queryUser", method = RequestMethod.POST) public Result<PageData<UserVO>> queryUser(@Valid @RequestBody QueryUserParameterVO parameter) { PageData<UserVO> pageData = userService.queryUser(parameter); return Result.success(pageData); }}
复制代码

后记

“仁者见仁、智者见智”,每个人都有自己的想法,而文章的内容也只是我的一家之言。


谨以此文献给那些我工作过的创业公司,是您们曾经放手让我去整改乱象,让我从中受益颇深并得以技术成长。


作者介绍


陈昌毅,花名常意,高德地图技术专家,2018 年加入阿里巴巴,一直从事地图数据采集的相关工作。


本文转载自公众号阿里巴巴中间件(ID:Aliware_2018)


原文链接


https://mp.weixin.qq.com/s/I_pfVRYLv5hlBA2JgAQxEQ


2019-08-12 08:005082

评论 5 条评论

发布
用户头像
哈哈,第一个想到的就是 mybatis plus 。。。从审美上就不能苟同那设计……
2019-08-17 15:41
回复
用户头像
看不太懂
2019-08-13 22:41
回复
用户头像
Controller中的@Value能正常注入啊 专门复制了作者的代码 发现能够成功注入啊

➜ ~ curl http://localhost:8080/test/access
系统 (foo) 欢迎您访问!%
2019-08-13 22:27
回复
用户头像
干货,对初中级开发人员很有帮助
2019-08-13 15:52
回复
用户头像
同感。有很多类似的场景现在还在重复中。
2019-08-12 10:00
回复
没有更多了
发现更多内容

为企业创建完美CRM系统策略

低代码小观

企业管理 CRM 客户关系管理 CRM系统 客户关系管理系统

技术三板斧:关于技术规划、管理、架构的思考

阿里技术

技术管理 技术人生 内容合集

架构实战-模块七-作业

无名

架构实战营 「架构实战营」

MySQL从入门到入魔之数据库连接池(04)

海拥(haiyong.site)

MySQL 数据库 28天写作 12月日更

EasyRecovery的高级设置如何使用

淋雨

数据恢复 EasyRecovery

拍乐云发布“融合语音通话”产品,实现多场景下VoIP和PSTN互通

拍乐云Pano

RTC PSTN VoIP 融合语音通话

Python代码阅读(第71篇):检测一个平坦列表中是否有重复元素

Felix

Python List 编程 阅读代码 Python初学者

在阿里,我如何做好技术项目管理?

阿里技术

技术管理 技术人生 内容合集

为什么大部分人做不了架构师?这2点是关键

阿里技术

技术人生 内容合集

在阿里做了五年技术主管,我有话想说

阿里技术

技术人生 内容合集

互联网时代,谁来保护我们的个人隐私信息?

郑州埃文科技

数据库 App IP 个人信息

华为超大云数据中心落地贵州,这些硬核技术有利支撑“东数西算”

华为云开发者联盟

服务器 数据中心 华为云 东数西算 云数据中心

面对复杂业务,if-else coder 如何升级?

阿里技术

技术人生 内容合集

百度API接口智能化测试探索与实践

百度开发者中心

自动化测试 API测试 智能化测试

CSS之变量(四)悬浮跟踪按钮

Augus

CSS 12月日更

Typora + picGo实现插入图片上传gitee图床

zdd

高质量的缺陷分析:让自己少写 bug

阿里技术

技术管理 技术人生 内容合集

Linux云计算有那么难学吗?Linux入门篇。系统常用函数的调用方法大全

学神来啦

MySQL nginx Linux Shell linux云计算

如何提高一个研发团队的“代码速度”?

阿里技术

技术管理 技术人生 内容合集

如何成为优秀的技术主管?你要做到这三点

阿里技术

技术管理 技术人生 内容合集

关于写文章的一点经验

阿里技术

技术管理 技术人生 内容合集

自用学习资料,Linux内核之【内存管理】的一些分享

奔着腾讯去

内存泄露 C/C++ Linux内核 内存映射 内存池

RPA的定义

金小K

RPA 自动化 自动化平台 自动化运维

揭开神秘面纱,如何组织一次分布式压测

博睿数据

7 个建议让 Code Review 高效又高质

阿里技术

技术管理 技术人生 内容合集

一周信创舆情观察(12.13~12.19)

统小信uos

优秀工程师必备的一项技能,你解锁了吗?

阿里技术

技术管理 技术人生 内容合集

OPPO大数据离线任务调度系统OFLOW

安第斯智能云

后端 数据

【浅谈黑客与学习思路】黑客的种类和行为,初学者应该怎样学习

H

黑客 网络安全 信息安全

一文带你了解什么是GitOps

华为云开发者联盟

DevOps 运维 测试 软件开发 gitops

毕业10年才懂,会升层思考,工作有多轻松?

阿里技术

技术管理 技术人生 内容合集

那些年,我们见过的 Java 服务端乱象_文化 & 方法_陈昌毅_InfoQ精选文章