
DDD是领域驱动设计的简写。前段时间听群友说行业里少有DDD的代码案例,进而对DDD没有一个感性的认识。我想这是行业里普遍存在的现象吧。所以,就有了写此文的想法。
文章标题说的是“同事的代码”,其实只是为了让此文更具传播,没别的意思。
本文开篇介绍了行业里比较普遍的代码风格,接着,我采用DDD风格对其进行修改。
我无意说服读者要按照我认为的DDD的风格来写代码,只是想告诉大家,这个世界上,还存在另一种代码风格。
本文虽是以Java语言为案例演示,也希望对其它语言的读者朋友有帮助。
如果各位觉得这样的风格好,可以尝试一下。非常欢迎大家反馈,平时太少人和我交流这些了。
如果你觉得此文对你有帮助,麻烦转发。干货好文不易。谢谢。
代码结构如下:
├── domain domain模块被同事认为是用于存放专门和DB打交道的类的地方- 包路径太长省略/account/repository/AbcLoginInfoRepository.java- 包路径太长省略/domain/account/AbcLoginInfo.java
├── repository-impl- 包路径太长省略/AbcLoginInfoRepositoryImlp.java
├── server- 包路径太长省略/server/login/LoginService.java- 包路径太长省略/server/login/LoginController.java- 包路径太长省略/server/login/AuthCodeVo.java- 包路径太长省略/server/login/UserInfoVo.java- 包路径太长省略/config/AbcWebMvcConfigurer.javaA风格下,整个业务系统的业务逻辑都在此模块中。
LoginController.java 实现http服务:
@Controller
@RequestMapping
public class LoginController {
@Autowired
LoginService loginService;
// 省略一些不重要的代码@GetMapping(value = "/login")
@ResponseBody
public UserInfoVo login(String code) throws IOException { UserInfoVo userInfoVo = loginService.login(code, httpServletResponse); httpServletResponse.sendRedirect("/"); return userInfoVo;
} @GetMapping(value = "/logout")
@ResponseBody
public boolean logout() { return loginService.logout(httpServletRequest,httpServletResponse);
}
}UserInfoVo.java是返回给前端的用户信息的结构体:
public class UserInfoVo { private String id; private String userType; // 省略一些其它字段// 省略一些getter setter方法
}AuthCodeVo.java是用于存储一些认证过程中的数据的结构体
public class AuthCodeVo { private String token; private Integer expiresIn; // 省略一些getter setter方法
}A风格的特点是:除了VO,行业里,还有各种O,如PO、DTO、DO。
刚入行的小伙伴很难分清各种O,所以,只有跟着前辈的老代码依葫芦画瓢。进而导致大家对于Java代码的印象:不就是各种O之间的转换嘛。
这里并不是说DDD风格下的代码没有O。在DDD风格下,O本身是有业务逻辑方法的,并不只是一堆字段、getter和setter方法。
AbcWebMvcConfigurer.java这个类用于实现对所有的请求的拦截,以实现统一认证:
@Configuration
public class AbcWebMvcConfigurer implements WebMvcConfigurer {
@Autowired
LoginService loginService; @Override
public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserAuthInterceptorRegistry()) // 省略代码
} class UserAuthInterceptorRegistry implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (loginService.isLoginSuccess(request)) { return true; } response.sendRedirect("登录页面的url"); return false; }
}
}AbcLoginInfo.java,AbcLoginInfoRepository.java,AbcLoginInfoRepositoryImlp.java 三个文件实现了登录信息的存储。
其中AbcLoginInfo.java只是用户的信息及getter和setter方法,典型的贫血型模型。
AbcLoginInfoRepository是AbcLoginInfo对象的持久化接口,而AbcLoginInfoRepositoryImlp是该接口的实现。
这个就是登录服务直接实现逻辑所在。源代码将近200行代码
@Service
public class LoginService {
// 省略一些不重要的代码
public UserInfoVo login(String code, HttpServletResponse response) { AuthCodeVo authCodeVo = authCode(code); // 省略部分代码UserInfoVo userInfoVo = getUserInfo(authCodeVo.getAccessToken(), authCodeVo.getExpiresIn()); // 省略部分代码LoginInfo loginInfo =loginInfoRepository.findByUid(userInfoVo.getUid()); if (loginInfo == null) { loginInfo = new LoginInfo(); } setLoginInfo(loginInfo, authCodeVo, userInfoVo); loginInfoRepository.save(loginInfo); addLoginCookie(loginInfo, response); return userInfoVo;
} private void addLoginCookie(LoginInfo loginInfo, HttpServletResponse response) { Cookie tokenCookie = new Cookie(TOKEN_COOKIE, loginInfo.getAccessToken()); response.addCookie(tokenCookie);
} public boolean isLoginSuccess(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { return false; } String token = null; String uid = null; // 此处省略代码,即从cookies中取出token和uid将设置到变量中。 LoginInfo loginInfo = loginInfoRepository.findByUid(uid); // 此处对acessToken和过期时间进行校验if (token.equals(loginInfo.getAccessToken()) && new Date().compareTo(loginInfo.getExpiresDate()) < 0) { return true; } return false;
} public boolean logout(HttpServletRequest request, HttpServletResponse response) { Cookie[] cookies = request.getCookies(); // 对cookie进行过期处理return true;
} private LoginInfo setLoginInfo(LoginInfo loginInfo, AuthCodeVo authCodeVo, UserInfoVo userInfoVo) { long nowTime = System.currentTimeMillis(); // 根据过期时长计算过期时间,并设置到LoginInfo中Date expiresDate = new Date(nowTime + authCodeVo.getExpiresIn() * 1000); // 此处省略一些拿authCodeVo和userInfoVo中的信息set到loginInfo的代码return loginInfo;
} public AuthCodeVo authCode(String code) { Map params = new HashMap<>(); // 省略params参数的组装的代码// 请求access token的地址,并拿到AuthCodeVo结构体的内容Map resultMap = restTemplate.postForObject(ACCESS_TOKEN_URL, null, Map.class, params); AuthCodeVo authCodeVo = new AuthCodeVo(); // 将resultMap中的值set到authCodeVo中return authCodeVo;
} public UserInfoVo getUserInfo(String accessToken, Integer expiresIn) { Map params = new HashMap<>(); // 省略params参数的组装的代码// 请求用户的信息的地址,并拿到用户信息。注意这里直接使用restTemplate这个技术实现。Map resultMap = restTemplate.getForObject(PROFILE_URL, Map.class, params); UserInfoVo userInfoVo = new UserInfoVo(); // 将resultMap中的值set到userInfoVo中return userInfoVo;
} } 1. 登录的主逻辑放在LoginService中;
2. LoginService即处理http请求技术逻辑(cookie的操作),也处理业务逻辑(登录信息的持久化、登录判断、token过期时间设置);
3. LoginService存放在Server模块;
4. 所有的实体、各种O中,只有字段,getter和setter方法。这导致lombok这样的代码生成库大量被使用,因为A风格觉得为每个字段写getter和setter方法是必须,但是又是浪费时间的事情。
我们暂不讨论A风格的问题,接着看DDD风格的代码。
代码仓库结构:
├── domain- domain是用于存放整个业务系统的核心逻辑
├── abc-o2-auth- 存放所有abc-o2的相关逻辑,下文详细介绍
├── server- 包路径太长省略/server/login/LoginController.java- 包路径太长省略/config/AbcWebMvcConfigurer.java
├── repository-impl- 包路径太长省略/AccountRepositoryImpl.java
├── spring-ioc-impl- spring的IoC的实现,D风格下,IoC的实现也应该是可以被低成本地替换的
├── tech-lib- 公共技术逻辑的接口
├── tech-lib-impl- 公共技术逻辑的接口的实现我们把o2-auth的所有的逻辑放在这个新的模块中。考虑到将来可能需要实现新的认证逻辑。以下是该模块的Java代码的结构:
./abc-o2-auth/src/main/java/com/xxx/domain/o2
├── AccessToken.java
├── AccessTokenFetcher.java
├── AccessTokenFetcherImpl.java
├── Account.java
├── AccountProfile.java
├── AccountProfileFetcher.java
├── AccountProfileFetcherImpl.java
├── AuthConfig.java
└── repository└── AccountRepository.javaAccount.java是整个o2-auth的认证方式的核心逻辑。源代码将近330行。
虽然它是一个实体,但它不是整个业务系统的核心逻辑,所以,没有被放到整个业务系统的domain模块中。
Account类中所有的技术逻辑都被抽象成接口。
公共的技术接口,比如Json数据的操作接口json-util,我们统一放在tech-lib模块中。公共的技术接口的实现,目前统一放在tech-lib-impl中。
当然,也可以以更小粒度的模块来实现解耦,比如创建一个json-util-jackson-impl模块来实现json-util接口。
P.S. 为什么不直接使用Jackson呢?你想想,当发生像Fastjson那样的安全事故时,你该如何快速的更换json的实现?如果按照D风格,是不是就很容易更换了。
当Account中的逻辑被真正运行时,需要用到这些技术接口的具体实现时,就从InstanceFactory实例工厂类的getInstance静态方法获取。InstanceFactory是什么,我们下面再说。
当前,你只需要知道,通过InstanceFactory的getInstance静态方法可以拿到接口的实现实例就可以了。
采用D风格,在写业务逻辑时,就不需要关心技术逻辑的实现了。这样就能很好的解决“无法写单元测试”的问题。
@Entity
@Table(name = "abc_o2_accounts")
public class Account {
// 省略所有的字段,getter和setter代码public static Optional login(String code) { // AccessTokenFetcher是accessToken的拉取接口// 因为accessToken需要请求第三方系统AccessTokenFetcher accessTokenFetcher = InstanceFactory.getInstance(AccessTokenFetcher.class); Optional accessTokenOptional = accessTokenFetcher.auth(code); if (accessTokenOptional.isEmpty()) { throw new LoginBizException("401"); } // AccountProfileFetcher是accountProfile的拉取接口AccountProfileFetcher accountProfileFetcher = InstanceFactory.getInstance(AccountProfileFetcher.class); Optional accountProfileOptional = accountProfileFetcher.fetch(accessTokenOptional.get().getAccessToken(), accessTokenOptional.get().getExpiresIn()); if (accountProfileOptional.isEmpty()) { throw new LoginBizException("401"); } // 登录成功后,将登录信息持久化AccountRepository accountRepository = InstanceFactory.getInstance(AccountRepository.class); Optional accountOptional = accountRepository.findByUid(accountProfileOptional.get().getUid()); if (accountOptional.isEmpty()) { Account account = buildBy(accessTokenOptional.get(),accountProfileOptional.get()); account.save(); return Optional.of(account); } else { Account account = accountOptional.get(); account.update(accessTokenOptional.get(), accountProfileOptional.get()); return Optional.of(account); }
}
// 登录的url是从配置中获取的。至于是从数据库,还是Etcd配置中心获取,登录核心逻辑并不关心,
// 而由AuthConfig的实现决定。这样,将来我们想换配置中心,成本就很低了。
public static String loginUrl(){ AuthConfig authConfig = InstanceFactory.getInstance(AuthConfig.class); return authConfig.getLoginUrlWithRedirect();
}
// 再次review此代码时,发现这个方法叫isLoggedIn更能体现方法内的逻辑。
public static boolean isLoginSuccess(String token, String uid) { AccountRepository accountRepository = InstanceFactory.getInstance(AccountRepository.class); Optional accountOptional = accountRepository.findByUid(uid); if (accountOptional.isEmpty()) { return false; } return StringUtils.equals(token, accountOptional.get().getAccessToken()) && new Date().before(accountOptional.get().getExpiresDate());
} public AccountProfile getProfile() { AccountProfile result = new AccountProfile(); // 将Account中的信息设置到AccountProfile中,因为前端只需要Account中的部分信息return result;
} private void update(AccessToken accessToken, AccountProfile accountProfile) { Date expiresDate = calExpiredDate(accessToken.getExpiresIn()); // 省略部分更新Account对象的代码。// save方法即保存此对象save();
} // 计算出最新的过期时间
private static Date calExpiredDate(int expiresIn) { long nowTime = System.currentTimeMillis(); return new Date(nowTime + expiresIn * 1000L);
}
// D风格的代码的一大特点:行为跟着数据走。
// 因为数据结构在Account类中,所以数据的持久化方法save也应该放在Account类中。
// 虽然底层实现都是accountRepository.save(xxx)
// A风格下,持久化方法放在LoginService中,而数据结构放在另一个类中。
private void save() { AccountRepository accountRepository = InstanceFactory.getInstance(AccountRepository.class); accountRepository.save(this);
} private static Account buildBy(AccessToken accessToken, AccountProfile accountProfile) { Account result = new Account(); // calExpiredDate方法的实现放在AccessToken类中更合理,我们只需要调用accessToken。getExpiresDate()。// 因为根据过期时长计算过期日期的逻辑应该属于AccessToken,而不属于AccountDate expiresDate = calExpiredDate(accessToken.getExpiresIn()); // 省略根据accessToken和accountProfile构建一个Account实例return result;
}
} LoginController.java 只负责调用Account实体的的login方法和操作Cookie这类、HTTP服务相关的技术逻辑。
@Controller
@RequestMapping
public class LoginController { @GetMapping(value = "/login")
@ResponseBody
public AccountProfile login(String code, HttpServletResponse response) throws IOException { Optional accountOptional = Account.login(code); if (accountOptional.isPresent()) {responseLoginCookie(accountOptional.get().getAccessToken(), accountOptional.get().getUid(), response); response.sendRedirect("/"); return accountOptional.get().getProfile(); } // 此处省略部分代码
} @GetMapping(value = "/logout")
@ResponseBody
public boolean logout(HttpServletRequest request, HttpServletResponse response) { Cookie[] cookies = request.getCookies(); // 遍历Cookie,并设置cookie过期return true;
}
private void responseLoginCookie(String accessToken, String uid, HttpServletResponse response) { // 登录成功,设置cookie
}
} 以下是D风格AbcWebMvcConfigurer的代码:
@Configuration
public class AbcWebMvcConfigurer implements WebMvcConfigurer { @Override
public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UserAuthInterceptorRegistry()) // 部分代码省略
} class UserAuthInterceptorRegistry implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Cookie[] cookies = request.getCookies(); //省略代码String token = null; String uid = null; // 从Cookie中取值,并设置到token和uid变量中 if (Account.isLoginSuccess(token, uid)) { return true; } response.sendRedirect(Account.loginUrl()); return false; }
}
}D风格的AbcWebMvcConfigurer.java 与A风格的区别是:
1. 关于Cookie的操作,A风格放在LoginService类中,而D风格放在AbcWebMvcConfigurer。因为D风格认为Cookie的操作属于HTTP服务行为,不属于核心业务。另,UserAuthInterceptorRegistry,可以考虑移到abc-o2-auth模块中;
2. 关于是否已经登录的判断逻辑,A风格放在LoginService类中,而D风格放在Account类中。因为是否已经登录的判断逻辑,D风格认为属于abc-o2-auth模块的核心逻辑,而不属于server模块。
3.
在D风格中会大量使用InstanceFactory静态类,它使我们能做到与IoC的实现的解耦。
InstanceFactory代码来自https://github.com/dayatang/dddlib 。
DDDLib是我的恩师所创建。我在十年前跟他学习到的DDD。大家可以start并从该仓库学习到DDD的一些代码样例。
1. 登录的主逻辑放在abc-o2-auth中模块的Account实体中。D风格中的实体类包含各种业务方法,是充血型模型。每一类的设计都有业务含义的,不仅仅只是一个数据结构;
2. 在写代码时,时刻在思考:这是技术逻辑,还是业务逻辑?这是核心业务逻辑,还是非核心逻辑。
5年前,我还是一名Java程序员的时候,我一直按照DDD风格要求自己。
但是,软件行业的绝大数公司才不管你写的代码好,还是坏。更不会管这代码在几年后还能否被维护,维护成本是多少。
这是DDD风格不流行的原因之一。另一个原因就是:根本没有几个人知道这样写代码。所以,本文算是一篇科普文。
(全文完)
如果对你有帮助,那么请你也帮助我转发,这是对我的支持。谢谢。
往期好文推荐:
这十年,我所经历的领域驱动设计(DDD)
耦合的本质
你们的 save 方法是写在实体上,还是写 DAO 上?