【AICon】AI 基础设施、LLM运维、大模型训练与推理,一场会议,全方位涵盖! >>> 了解详情
写点什么

那些年,我们见过的 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:005089

评论 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
回复
没有更多了
发现更多内容

面试细节: i = i++和 i = ++i

Java小咖秀

面试 JVM 经验分享

测试开发工程师修炼手册—测试技能大盘点

Zoe

测试工程师产出

Redis分布式锁课堂开课了!

小闫

redis Spring Cloud Redis项目

java架构-一些设计上的基本常识

猿灯塔

Java

Week5命题作业

星河寒水

极客大学架构师训练营

18个Java8日期处理的实践,太有用了建议收藏

码哥小胖

MySQL SQL语法 sql查询

架构师训练营第4周总结

aoeiuvzcs

程序员的晚餐 | 7 月 3 日 好久没做饭

清远

美食

一个简单的技术选型心得

i风语

Java 架构

Redis系列(五):你要的Redis集群搭建来了,实践与否你自己选!

z小赵

Java redis 分布式 高并发

深入理解编译优化之循环展开和粗化锁

程序那些事

JIT 编译优化 循环展开 粗化锁

简直了!顶级架构师分享心得,如何在项目中兼容多种数据库

犬来八荒

Java MySQL 数据库 面试

五分钟让你搞懂Nginx负载均衡原理及四种负载均衡算法

架构大数据双料架构师

游戏夜读 | 互动剧的黎明到了?

game1night

Go: 字符串和转换优化

陈思敏捷

string 字符串 Go 语言

我是如何解决邮件焦虑的

vinkyqy

效率 职场 邮件

原创 | TDD工具集:JUnit、AssertJ和Mockito (二十五)运行测试-在IDE中运行测试

编程道与术

Java intellij-idea 编程 TDD 单元测试

猿灯塔:疫情冲击,去体验远程面试被怼10分钟,今年Java开发找工作真难

猿灯塔

逆袭之路,普通二本的八年开发码农如何进阿里拿年薪百万

小谈

Java 面试

六月我在工作中蜕变,勤奋小人打架终于赢了

程序员小跃

效率工具 加班 沟通 复盘

向女朋友解释乐观锁与悲观锁的小妙招!

小闫

spring 面试 Spring Cloud 乐观锁 悲观锁

理解 Mysql 索引底层原理只需这一篇就够了

小谈

MySQL 数据结构 面试 Spring Cloud Spring Boot

阿里技术官:这样带你学Spring全家桶,其实没你想的那么难

小吴选手

spring Spring Cloud Spring Boot

太牛 了!快码住!GitHub上标星75k!超牛的《Java面试突击版》

犬来八荒

Java git Linux 面试

高承实:区块链在新基建中的作用和未来发展

CECBC

新基建 政策扶持 技术特征 链上数据 产业场景

分布式缓存

Axe

Android架构组件-App架构指南,你还不收藏嘛

小吴选手

架构 架构师 架构总结 架构要素 P7架构师

马匹、马镫、马车,和华为的数据基础设施革新

脑极体

为什么我建议你读一读历史?

Phoenix

历史 中国历史

ARTS Week6

时之虫

ARTS 打卡计划

第四周

仪轩

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