DDD系列二:重新认识面向对象开发,颠覆传统认知,打破CUR

DDD系列一:重新认识面向对象开发,颠覆传统认知,打破CURD困境

学习方式自下而上:提出问题 -> 分析问题 -> 解决问题 -> 总结

需求场景

1619145035912.jpg

业务需求

一、输入手机号,获取短信验证码;
二、输入验证码,点击登陆;
   1、判断手机是否注册,未注册则注册,已注册则重新登陆;
   2、注册流程:记录登陆时间、生成token、通过手机号生成昵称
   3、登陆流程:记录登陆时间、重新生成token
复制代码

用户表结构

CREATE TABLE `users` (
 `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID',
 `mobile` varchar(11) NOT NULL DEFAULT '' COMMENT '用户手机',
 `nickname` varchar(60) NOT NULL DEFAULT '' COMMENT '昵称',
 `token` varchar(255) NOT NULL DEFAULT '' COMMENT 'token',
 `login_at` datetime DEFAULT NULL COMMENT '`登陆时间`',
 PRIMARY KEY (`id`),
 UNIQUE KEY `mobile` (`mobile`),
 UNIQUE KEY `token` (`token`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4
复制代码

参数验证第一种实现方法

注:为了代码简洁,代码不全,只展示需要的代码,注解也去了很多,完整代码请参考源码

public class UserController {
    public Response sendCaptcha(String mobile) {
        // 手机号参数验证
        isMobile(mobile);
        /** 发送验证码逻辑省略 **/
        return success();
    }
    // 验证手机号函数
    private void isMobile(String mobile) {
        Pattern p = Pattern.compile("^[1][3,4,5,6,7,8,9][0-9]{9}$");
        Matcher m = p.matcher(mobile);
        if  (!m.matches()) {
            throw new Exception("手机号格式不正确");
        }
    }
}
POST请求:http://127.0.0.1/user/sendCaptcha?mobile=13000000000
--------------------------------------------------------------------------------


代码分析:
参数验证函数放在Controller层;
Controller层职责:从HTTP请求中获得信息,提取参数,并分发给不同的处理服务;

问题1:违背了Controller层的职责,这种写法导致Controller层充斥着大量的验证函数;
问题2:
新增用户收货地址需求:用户有多个收货地址,用户可以新增或修改收货地址,其中有手机号参数验证;
如果按这种实现方式的话,AddressController需要复制一份isMobile函数,导致了代码重复;
复制代码

重复代码是软件质量下降的重大来源!!!

重复代码会造成维护成本的成倍增加;
重复的代码如果在一个地方修改,在另外一个地方忘记修改,就会产生到处是bug(例如手机号码增加12开头的验证规则),它还会使你的代码体积变得臃肿;

参数验证第二种实现方法

public class Utils {
    public static void isMobile(String mobile) {
        Pattern p = Pattern.compile("^[1][3,4,5,6,7,8,9][0-9]{9}$");
        Matcher m = p.matcher(mobile);
        if  (!m.matches()) {
            throw new Exception("手机号格式不正确");
        }
    }
}
--------------------------------------------------------------------------------
public class UserController {
    public Response sendCaptcha(String mobile) {
        // 手机号参数验证
        Utils.isMobile(mobile);
        /** 发送验证码逻辑省略 **/
        return success();
    }
}
POST请求:http://127.0.0.1/user/sendCaptcha?mobile=13000000000
--------------------------------------------------------------------------------
代码分析:
提取出验证逻辑,工具类Utils实现isMobile函数
问题:把验证逻辑提取到Utils工具类,现实开发中也比较常见,
     但是代码以后会大量出现Utils.isMobile、Utils.isEmail,大量的重复的低质量代码
复制代码

思考:从面向对象的思想出发,isMobile属于Utils对象的动作或行为吗?

参数验证第三种实现方法

public class User {
    private Integer id;
    
    @NotNull(message = "短信验证码不能为空")
    @Size(min = 4, max = 4, message = "短信验证码只能四位")
    private String captcha;

    @NotBlank(message = "手机号码不能为空")
    @NotNull(message = "手机号码不能为空")
    @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "手机号格式不正确")
    private String mobile;
    
    private String token;
    private String nickname;
    private LocalDateTime loginAt;
}
--------------------------------------------------------------------------------
public class UserController {
    public Response sendCaptcha(User user) {
    
       // 发送验证码只需要校验mobile,captcha不需要校验
       // 下面两行代码可以单独验证User类mobile属性的参数校验注解
        Validator<User> validator = new Validator<>();
        validator.validateProperty(user, "mobile");
        
        /** 发送验证码逻辑省略 **/
        return success();
    }
}
POST请求:http://127.0.0.1/user/sendCaptcha?mobile=13000000000
--------------------------------------------------------------------------------
代码分析:
使用注解的方式实现的参数的自动校验,也是现在比较流行的实现方式

用户收货地址需求,新建Address实体,注解属性进行验证
public class Address {
    private Integer id;

    @NotNull(message = "短信验证码不能为空")
    @Size(min = 4, max = 4, message = "短信验证码只能四位")
    private String captcha;

    @NotBlank(message = "手机号码不能为空")
    @NotNull(message = "手机号码不能为空")
    @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "手机号格式不正确")
    private String mobile;

    private String consignee;
    private String province;
    private String city;
    private String district;
    private String detail;
}

问题:同上,注解中出现了大量的重复验证代码
复制代码

思考:手机号码校验逻辑是User对象、Address对象的动作或行为吗?到底放在哪里合适呢?

问题分析

面向对象是以对象为中心的开发方法,这里所说的对象是现实世界里的对象,不是随便建几个类就是面向对象开发;

现实世界里的手机号码,不仅仅是一串数字字符,它有区号(86)、长度限制(11位)、以1[3,4,5,6,7,8,9]开头、4G、5G的区别,还有运营商的不同,它是真实存在的一个活生生的对象,它有自己的属性和行为,把它的属性和行为分配给别的对象,有考虑过它的感受吗?

同理,短信验证码在现实世界里也有自己的属性和行为,可能是4位数字组成,有过期时间,有短信内容(####为您的登录验证码,如非本人操作,请忽略本短信。)等等。

短信验证码属性:
1、码值:value
2、过期时间:expire
3、需要验证的手机号:mobile
4、短信文本内容:content
短信验证码行为:
1、生成码值
2、验证码值格式是否正确
3、生成码值
4、生成短信文本内容
复制代码

参数验证面向对象实现

public class Mobile {

    @NotBlank(message = "手机号码不能为空")
    @NotNull(message = "手机号码不能为空")
    @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "手机号格式不正确")
    private String value;

    public void valid() {
        Validator<Mobile> validator = new Validator<>();
        validator.validate(this);
    }
}
--------------------------------------------------------------------------------
public class Captcha {

    @NotNull(message = "短信验证码不能为空")
    @Size(min = 4, max = 4, message = "短信验证码只能四位")
    private String value;

    @Valid
    private Mobile mobile;
    
    private static long expire = 60; // 过期时间
    private String content = "%s为您的登录验证码,如非本人操作,请忽略本短信。";

    // 生成随机四位数字
    public void generateValue() {
        value = String.valueOf((int)(Math.random() * 9000 + 1000));
    }
    // 生成短信内容
    public void generateContent() {
        content = String.format(content, value);
    }
}
--------------------------------------------------------------------------------
public class CaptchaController {
    public Response send(Captcha captcha) {
        // 发送验证码只需要校验mobile,captcha不需要校验
        captcha.getMobile().valid();

        /** 发送验证码逻辑省略 **/
        return success();
    }
}
POST请求:http://127.0.0.1/captcha/send?mobile.value=13000000000
--------------------------------------------------------------------------------
代码分析:
1、新建Mobile实体,value属性是手机号码的值,注解value校验规则,Mobile实体自带自验证函数valid
2、新建Captcha实体,Captcha实体包含Mobile实体,形成嵌套对象;
3、独立CaptchaController,使用Captcha映射请求参数,
   调用Captcha实例中Mobile属性的自验证函数valid校验参数是否正确
4、注意POST请求参数:mobile.value=13000000000
5、用户收货地址需求,新建Address实体,包含Mobile实体,形成嵌套对象;
public class Address {
    private Integer id;
    
    @Valid
    private Mobile mobile;

    private String consignee;
    private String province;
    private String city;
    private String district;
    private String detail;
}
复制代码

登陆功能参数验证代码实现

public class UserController {
    public Response login(@Valid Captcha captcha, User user) {

        user = /** 发送验证码逻辑省略 **/;
        return success(user);
    }
}
POST请求:http://127.0.0.1/user/login?value=1234&mobile.value=13000000000
--------------------------------------------------------------------------------
代码分析:
1、使用@Valid注解实现短信验证码和手机号码同时校验
2、注意请求参数:value=1234&mobile.value=13000000000
   value是短信验证码
   mobile.value是手机号码 
复制代码

总结

一、验证逻辑封装在各自的实体中,有实体负责验证逻辑,验证逻辑不会散落在项目代码各处,当验证逻辑改变时,找到对应的实体修改就可以了,这就是代码的高内聚;

二、通过不同实体的嵌套组合就可以实现多样的验证需求,使得代码的可重用性大大增强,这就是代码的低耦合

发送短信验证码代码实现

public class CaptchaController {
    CaptchaService captchaService;
    public Response send(Captcha captcha) {
        captchaService.send(captcha);
        return success();
    }
}
--------------------------------------------------------------------------------
public class CaptchaService {
    // 面向对象
    public void send(Captcha captcha) {
        // 生成随机四位数字
        captcha.generateValue();
        // 生成短信内容
        captcha.generateContent();
        // 发送短信
        sendSMS(captcha);
        // 存储
        save(captcha);
    }
    
    // 面向过程或者是面向功能点
    public void send(Captcha captcha) {
        // 生成随机四位数字
        String code = String.valueOf((int)(Math.random() * 9000 + 1000));
        // 生成短信内容
        content = String.format(captcha.getContent(), code);
        // 发送短信
        sms.send(captcha.getMobile().getValue(), content);
        // 存储
        repository.save(captcha);
    }
}
--------------------------------------------------------------------------------
public class Captcha {
    private String value;
    private Mobile mobile;
    
    private static long expire = 60; // 过期时间
    private String content = "%s为您的登录验证码,如非本人操作,请忽略本短信。";
    
    private void generateValue() {
        value = String.valueOf((int)(Math.random() * 9000 + 1000));
    }
    private void generateContent() {
        content = String.format(content, value);
    }
}
--------------------------------------------------------------------------------
代码分析:
请体会面向对象和面向过程代码实现的不同点
复制代码

在面向对象普及之前,主流的开发方法是“面向功能”的,具体地说,就是把握目标系统整体的功能,将其按阶段进行细化,分解为更小的部分。如果采用面向功能的开发方法来编写软件,当规格发生改变或者增加功能时,修改范围就会变得很广,软件也很难重用。

面向对象技术的目的是使软件的维护和重用变得更容易,其基本思想是重点关注各个构件,提高构件的独立性,将构件组合起来,实现系统整体的功能。通过提高构件的独立性,当发生修改时,能够使影响范围最小,在其他系统中也可以重用。
--摘自《面向对象是怎样工作的》

怎么理解上述的这段文字,感觉说的都挺对,但是看了又等于白看,还是以例子来说明

新增需求:
短信内容增加有效期描述,"%s为您的登录验证码,请于%s分钟内填写,如非本人操作,请忽略本短信。"
复制代码

新增需求实现过程分析:
一、面向功能点实现的send函数,它是一个整体,如果想修改其中的逻辑,第一步是要通读整个send函数的代码,理清上下文的业务规则,然后才敢下手改代码,现在只是几行代码,如果是个复杂的功能点,一两百行代码,通读一遍,理清规则,就需要耗费很多的精力,这就是代码的可读性差,可维护性低;

二、面向对象实现的send函数,它是有不同的构件组合而成的,现在只需要把注意力集中在Captcha构件的generateContent函数,找到这个函数所需要的expire、content属性,这样缩小了需要理解的业务逻辑的上下文,提高了实现新增业务代码的效率,这就是代码的可读性好,可维护性高;

修改代码如下:
public class Captcha {
    private static long expire = 60;
    private String content = "%s为您的登录验证码,请于%s分钟内填写,如非本人操作,请忽略本短信。";
   
    private void generateContent() {
        long minute = expire / 60;
        content = String.format(content, value, minute);
    }
}
复制代码

代码可测试性也是代码质量的重要评价标准

测试过程分析:
一、面向功能点实现的send函数,它是一个整体不可分割,只能针对整个函数进行测试,同时函数又依托第三方的短信发送服务,仓储层的存储服务,导致单元测试特别难写,这就是代码的可测试性差;

二、面向对象实现的send函数,可以很容易的实现Captcha的generateValue、generateContent函数的单元测试,这就是代码可测试性高;

注:短信发送服务、仓储存储服务都属于硬件层,单元测试主要集中在业务逻辑规则的测试
复制代码

用户登陆功能代码实现

public class Nickname {
    private String value;
    public void generate(String mobile) {
        StringBuilder sb = new StringBuilder(mobile);
        value = sb.replace(3, 8, "*****").toString();
    }
}
--------------------------------------------------------------------------------
public class Token {
    private String value;
    public void generate(String val) {
        val += LocalDateTime.now().toString();
        value = DigestUtils.md5DigestAsHex(val.getBytes());
    }
}
--------------------------------------------------------------------------------
public class User {
    private Integer id;
    private Mobile mobile = new Mobile();
    private Token token = new Token();
    private Nickname nickname = new Nickname();
    private LocalDateTime loginAt;

    public void reLogin() {
        // 更新登陆时间
        loginAt = LocalDateTime.now();
        // 重新生成token
        token.generate(mobile.getValue());
    }

    public void register() {
        // 更新登陆时间
        loginAt = LocalDateTime.now();
        // 生成token
        token.generate(mobile.getValue());
        // 生成昵称
        nickname.generate(mobile.getValue());
    }
}
--------------------------------------------------------------------------------
public class UserService {
    UserRepository repository;
    public User loginOrRegister(User user) {
        // 手机号是否注册
        User u = repository.findByMobile(user.getMobile());
        if (u != null) {
            u.reLogin(); // 重新登陆
            save(u);
            return u;
        }
        
        user.register(); // 新用户注册
        save(user);
        return user;
    }

--------------------------------------------------------------------------------
public class UserController {
    UserService userService;
    public Response login(@Valid Captcha captcha, User user) {
        user = userService.loginOrRegister(user);
        return success(user);
    }
}
POST请求:http://127.0.0.1/user/login?value=1234&mobile.value=13000000000
--------------------------------------------------------------------------------
代码分析:
1、User实体嵌套Mobile、Nickname、Token实体
2、重点体会User实体reLogin、register的实现方式和实现过程
复制代码

总结

对象的属性变化都是由现实世界的具体的行为导致的,用户的重新登陆导致登陆时间和token的改变,而把这些行为封装为具体对象的方法,通过这些对象方法的重新组合,可以实现现实世界里不同的行为需求,比如reLogin和register的实现过程;注意方法要足够小,每个方法只专注干一件事,只有方法足够小,才能组合搭配成更多更复杂的方法去实现多样的业务需求,这也是单一职责的含义,单一职责不仅仅适用类,还有类方法;

面向对象的本质就是分而治之,实现分支思想的前提是组件屏蔽内部复杂性,组件之间低耦合,如果不基于低耦合,把内部错中复杂的关系暴露给上层,就造成了组件之间互相关联,降低了组件的独立性,导致组件很难复用。

有时使用OOP编写的方法只有几行,甚至一行。虽然并不是所有方法都要这么小,但是在使用OOP的情况下,一个方法的上限应该是二三十行。 --摘自《面向对象是怎样工作的》

后记

实体的方法都是纯内存操作,没有返回值,无状态

细心的读者会发现Captcha.generateValue、Captcha.generateContent、User.reLogin、User.register实体函数都没有返回值,对象方法操作的都是根据业务逻辑操作本对象的属性,如果有返回值就需要考虑函数的适用场景正确不正确;

注:有状态是指存在依赖关系、数据存储等各种问题,无状态则相反

思考:是不是每个属性都要实现一个实体,比如增加用户名称name属性,在User实体中嵌套一个Name实体;

现实开发中也有这么干的,比如id属性也是一个独立的Id实体,觉得太麻烦太学院派;
参考标准:Mobile、Token、Nickname实体

误区:面向对象的三大特性是"封装、"多态"、"继承"

《面向对象是怎样工作的》中明确把类、多态和继承定义成编程结构:
类、多态和继承应该被明确定义为能提高软件的可维护性和可重用性的结构。
类用于将变量和子程序汇总在一起,创建独立性高的构件;
多态和继承用于消除重复代码,创建通用性强的构件。

区分业务流程和业务规则:业务规则是有if/else的,业务流程没有!

这个问题也困扰了很多年,可以这样思考,如果有分支语句说明存在基于业务规则上的逻辑判断,逻辑判断应该写在对应的对象方法里;

注:业务流程并不是绝对没有if/else,下面会说明。

service职责:
1、业务流程
2、实体对象的创建和销毁(生与死)
public class CaptchaService {
    public void send(Captcha captcha) {

        // 生成验证码信息(业务流程第一步)
        captcha.generate();
        // 发送短信 (业务流程第二步)
        sendSMS(captcha);
        // 实体对象的销毁
        save(captcha);
    }
}

public class UserService extends BaseService<User> {
    UserRepository repository;
    public User loginOrRegister(User user) {

        // 手机号是否注册 
        User u = repository.findByMobile(user.getMobile()); // 实体对象的创建

        // 这里业务流程怎么出现了if语句,可以这样理解,这个if是业务流程的前置条件,
        // 对具体业务reLogin、register的逻辑处理没有影响。
        if (u != null) {
            // 重新登陆
            u.reLogin();

            // 实体对象的销毁
            save(u); 
            return u;
        }
        // 新用户注册
        user.register();
        // 实体对象的销毁
        save(user); 

        return user;
    }
}

controller负责服务编排:
public class UserController extends BaseController {

    UserService userService;
    CaptchaService captchaService;

    public Response login(@Valid Captcha captcha, User user) {
        // Controller用法:从HTTP请求中获得信息,提取参数,并分发给不同的处理服务
        // 验证码是否正确(调用CaptchaService服务)
        captchaService.check(captcha);
        // 用户登陆或注册(调用UserService服务)
        user = userService.loginOrRegister(user);

        return success(user);
    }
}
复制代码

实体方法是对实体属性具体逻辑的操作,通俗来讲就是:增删改

查询是数据的不同展示形式,不包括在实体方法里,直接在Service层调用Dao层实现

失血,贫血,充血,胀血四种模型

1、失血模型
失血模型中,实体只有属性的get set方法的纯数据类,所有的业务逻辑完全由Service层来完成的
2、贫血模型
贫血模型中,实体包含了不依赖于持久化的原子领域逻辑,而组合逻辑在Service层。
3、充血模型
充血模型中,绝大多业务逻辑都应该被放在实体里面,包括持久化逻辑,而Service层是很薄的一层,仅仅封装事务和少量逻辑,不和DAO层打交道。

注:文中代码是贫血模型

自动生成各语言版本的model

参数问题:1、各语言版本的model 自动拼接生成URL参数字符串,如php的http_build_query 2、model直接转为json,json作为参数,服务端json映射为model

题外