从实际项目中学设计模式:策略模式与模板模式的应用

时间:2021-07-12
本文章向大家介绍从实际项目中学设计模式:策略模式与模板模式的应用,主要包括从实际项目中学设计模式:策略模式与模板模式的应用使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

也许有非常多的人都有这样的疑问,明明看了很多设计模式的书,看的时候感觉每个设计模式也都能看懂,但是就是用不起来,而一旦不用起来,过一阵又都忘得差不多了,下次又得重新学。如果你总是停留在学的这个阶段,而不去真正用起来,往往都会往复的循环。所以说要想学好设计模式,关键还是在于用。

至于如何去用,效果最好的还是从自己实际的项目中去找场景,设计模式就是解决某一类问题套路,你需要思考的问题就是项目中有哪些场景存在着和这个模式类似的问题,一旦发现了同样的问题,我们要做的就是套用模式而已,而只要你用设计模式解决了项目场景中的问题,那么对于你来说这套设计模式就已经掌握了。

也许你一开始实在想不到项目中哪些地方可以应用对应的设计模式,那么我来帮你,在这里我将会对我们项目常见的功能,进行设计模式的分析和应用,我们不再是对概念抽丝剥茧,而是从实实在在的项目功能中去应用设计模式,从中感受到设计模式给我的程序带来的简洁和扩展性,从熟悉的地方去应用设计模式,那么你想不掌握都难。

从"登录功能"中发现问题。

用户登录这个功能我想凡是学过编程的人对这个功能都不会陌生,也正是因为这个大家熟知的功能,我们挑这个功能进行重构会使大家对设计模式理解得更透彻,而且理解之后也能切实的应用起来,然后在项目中结合类似的场景真的把“策略模式”和“模板模式”用得熟练和巧妙。

首先我们简单的了解功能需求:

当然项目最初期的功能往往很简单,产品经理要求不高,只做了下面几个要求:

1、可以通过用户名密码、手机号验证码两种方式登录系统。

2、登录失败次数不能超过5次,超过5次的话就锁定账户30分钟。

3、已经禁用的用户不能登录系统。

于是你开始干活了:

好了听到这个需求后,这就是一个简单的需求,想想不过就是提供2个不同的登录方法,然后根据不同的登录类型调用不同的登录方式就行了,于是你拿起键盘就开始写代码了,于是乎你的代码也许就和下面类似。

1、控制层代码如下,根据不同的登录方式调用不同的服务层方法:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
public class LoginController{
@Autowired
    private  LoginService loginService;
@RequestMapping(value = "/login", method = RequestMethod.POST)
    public Result login(String username, String content, int loginType) {
Result result=null;
        if(loginType==1){
            //密码登录
            result= loginService.loginByPassword(username,content);
        }else if(loginType==2){
            //手机验证码登录
            result=loginService.loginBySMS(username,content);
        }
        return result;
    }
}

2、服务层提供两种登录方式,并对登录业务逻辑进行处理:

public interface LoginService {
/**
     * 密码登录
     * @return
     */
    Result loginByPassword(String userName, String password);
/**
     * 短信验证码登录
     * @return
     */
    Result loginBySMS(String username, String smsCode);
}
import org.apache.commons.lang3.time.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
    private  userDao userDao;
@Override
    public Result loginByPassword(String userName, String password) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        if(userInfo==null){
            return new Result("001","用户不存在",null);
        }
        //检查用户是否被禁用了(0正常,1禁用)
        if(userInfo.getStatus()==1){
            return new Result("002","账号已被禁用",null);
        }
//检查用户是否被锁定了。
        Date lockEndTime=userInfo.getLockEndTime();
        if(lockEndTime!=null&&lockEndTime.getTime()>System.currentTimeMillis()){
            return new Result("003","账户已锁定",null);
        }
//验证密码是否正确
        if(!userInfo.getPassword().equals(password)){
            //密码错误后,累计登录错误次数
            userInfo.setFailNumber(userInfo.getFailNumber()+1);
            //检查登录错误次数是否超过5次,超过则锁定账户30分钟。
            if(userInfo.getFailNumber()>5){
                Date locTime= DateUtils.addMinutes(new Date(),30);
                userInfo.setLockEndTime(locTime);
            }
            userDao.update(userInfo);
            return new Result("004","账户名或密码错误",null);
        }
//密码正确后返回用户信息和登录的token
        return new Result("200","成功","生成的token");
    }
@Override
    public Result loginBySMS(String username, String smsCode) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(username);
        if(userInfo==null){
            return new Result("001","用户不存在",null);
        }
        //检查用户是否被禁用了(0正常,1禁用)
        if(userInfo.getStatus()==1){
            return new Result("002","账号已被禁用",null);
        }
//检查用户是否被锁定了。
        Date lockEndTime=userInfo.getLockEndTime();
        if(lockEndTime!=null&&lockEndTime.getTime()>System.currentTimeMillis()){
            return new Result("003","账户已锁定",null);
        }
//验证码是否正确
        String code=userDao.querySmsCode(username);
        if(!code.equals(smsCode)){
            //验证码错误后,累计登录错误次数
            userInfo.setFailNumber(userInfo.getFailNumber()+1);
            //检查登录错误次数是否超过5次,超过则锁定账户30分钟。
            if(userInfo.getFailNumber()>5){
                Date lockTime= DateUtils.addMinutes(new Date(),30);
                userInfo.setLockEndTime(lockTime);
            }
            userDao.update(userInfo);
            return new Result("004","验证码错误",null);
        }
//密码正确后返回用户信息和登录的token
        return new Result("200","成功","生成的token");
    }
}

3、持久层,负责用户的数据查询和变更。

public interface userDao {
public UserInfo queryByUserName(String userName);
public int update(UserInfo userInfo);
public String querySmsCode(String phone);
}

4、还有Model

@lombok.Data
public class UserInfo {
    private  String userName; //用户名
    private  String password;    //密码
    private  String phone;    //手机号码
    private  int status;    //账户状态
    private Date lockEndTime;    //锁定结束时间
    private int failNumber;    //登录失败次数
}
@Data
public class Result {
//响应状态码
    private String code;
    //响应状态消息
    private String message;
    //响应内容
    private Object content;
public Result(String code, String message, Object content) {
        this.code = code;
        this.message = message;
        this.content = content;
    }
}

从代码中发现问题

第一次优化(代码抽离)

如果你是个爱学习并且对自己有要求的工程师,那么一定会在写完代码后尝试去优进行优化,于是你从上面代码中肯定会发现一个问题,在service层两个登录方法里面有大量重复的逻辑:

1、不管是密码登录,短信验证码登录都需要进行用户状态的检查

2、登录失败都需要记录登录错误次数、锁定账户;

3、登录成功,都是生成token,返回部分用户信息。

于是你想到了把共用的代码可以抽离出来单独成为一个方法,service层代码会变成这样。

import org.apache.commons.lang3.time.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
    private  userDao userDao;
@Override
    public Result loginByPassword(String userName, String password) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        //检查用户状态
        Result result=checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //验证密码是否正确
        if(!userInfo.getPassword().equals(password)){
            //登录失败处理
            loginFail(userInfo);
            return new Result("004","账户名或密码错误",null);
        }
        //密码正确后返回用户信息和登录的token
        result=loginSuccess(userInfo);
       return result;
    }
@Override
    public Result loginBySMS(String username, String smsCode) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(username);
        //检查用户状态
        Result result=checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //验证码是否正确
        String code=userDao.querySmsCode(username);
        if(!code.equals(smsCode)){
            //登录失败处理
            loginFail(userInfo);
            return new Result("004","验证码错误",null);
        }
        //密码正确后返回用户信息和登录的token
        result=loginSuccess(userInfo);
        return result;
    }
/**
     * 检查用户状态
     * @param userInfo
     * @return
     */
    private Result checkUserStatus(UserInfo userInfo) {
        if(userInfo==null){
            return new Result("001","用户不存在",null);
        }
        //检查用户是否被禁用了(0正常,1禁用)
        if(userInfo.getStatus()==1){
            return new Result("002","账号已被禁用",null);
        }
        //检查用户是否被锁定了。
        Date lockEndTime=userInfo.getLockEndTime();
        if(lockEndTime!=null&&lockEndTime.getTime()>System.currentTimeMillis()){
            return new Result("003","账户已锁定",null);
        }
        return null;
    }
/**
     * 登录成功
     * @param userInfo
     * @return
     */
    private Result loginSuccess(UserInfo userInfo) {
        Map map=new HashMap<>();
        map.put("userInfo",userInfo);
        map.put("token","生成的token信息");
        return new Result("200","成功",map);
    }
/**
     * 登录失败
     * @param userInfo
     */
    private void loginFail(UserInfo userInfo) {
        //验证码错误后,累计登录错误次数
        userInfo.setFailNumber(userInfo.getFailNumber()+1);
        //检查登录错误次数是否超过5次,超过则锁定账户30分钟。
        if(userInfo.getFailNumber()>5){
            Date lockTime= DateUtils.addMinutes(new Date(),30);
            userInfo.setLockEndTime(lockTime);
        }
        userDao.update(userInfo);
    }
}

于是乎代码经过优化了一遍之后变成了上面的样子,是不是感觉比之前好多了,不仅仅方法逻辑清晰了很多,而且以后不管是登录失败、登录成功、检查用户状态要新增加别的逻辑也只需要修改一个方法就行了,而不需要像之前一样每个设计到的方法都需要去修改代码。

第二次优化(增加策略模式)

做了上一次的优化后有好一阵子都感觉自己代码美美的,但是最近产品经理的一次需求变更让我嗅到了危险的气息,产品经理提出新需求要增加一种登录方式,用户使用指纹也可以登录系统,我尝试得知以后还会不会增加其他的登录方式,很显然得到得回复是不一定。其实要增加一种登录方式也不麻烦,增加一个登录方法,在原来的代码上增加一个if条件判断就行了,于是乎你的代码就成了下面这样。

1、在controller增加一个条件判断,调用新增加手势登录方法

2、service 里增加手势登录的方法

继续从上面代码中发现问题:

对于上面的代码你似乎总感觉不妥,每增加一种登录方式就需要对controller和service里面的代码进行修改,而每一次对原代码的修改就必定导致整个登录功能要重新测试一遍,似乎这样的代码并不友好,我们也知道好的代码应该是“对修改关闭,对扩展开放的”,很显然我们的代码并不符合这项原则。

如何消除掉这些if(){} , else if(){}语句,让我们新增功能的时候不需要修改原来的代码呢,我想很多人的代码中也会有大量的if(){} , else if(){}的逻辑语句,其实只要是这样的逻辑我们就可以想到“策略模式”,因为 if(){} , else if(){}本身就是一种策略逻辑,凡是使用这种逻辑的地方都可以使用策略模式代替。

策略模式的定义

定义:定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。

主要解决:在有多种算法相似的情况下,使用 if...else 所带来的复杂和难以维护。

何时使用:一个系统有许多许多类,而区分它们的只是他们直接的行为。

如何解决:将这些算法封装成一个一个的类,任意地替换。

关键代码:实现同一个接口。

在登录功能上应用策略模式

其实不管哪种方式登录,他们都属于一种登录行为,只是他们在不同的登录方式上验证的信息指标不同而已,那么意味着我们可以把登录行为定义成一个接口,具体如何登录就根据不同的登录行为交给对应的登录实现类,统一对外暴露的对象类型都是这个接口类型。

那么基于这种方式,我们每次新增加登录方式都是新增加一个新的实现类,是不需要修改原来的任何代码的,下面是我们整体思路的结构关系图。

经过策略模式变更后的代码如下:

controlle类

@RestController
@RequestMapping("/user")
public class LoginController{
@RequestMapping(value = "/login", method = RequestMethod.POST)
    public Result login(String username, String content, Integer loginType) {
        //根据登录类型,获取对应登录策略处理对象
        LoginService loginService= LoginService.loginServerMap.get(loginType);
        Result result=loginService.login(username,content);
        return result;
    }
}

登录接口

face LoginService {
 
  //缓存登录方式和处理策略对象的关系
   Map<String,LoginService>  loginServerMap=new HashMap();

    /**
     * 登录
     * @return
     */
    Result login(String userName, String password);

}

密码登录

/**
 * 密码登录实现
 */
@Service("passwordLoinService")
public class PasswordLoinService implements  LoginService {

    @Autowired
    private  userDao userDao;

    public PasswordLoinService() {
          //把自己,和自己处理的loginType注册到缓存
        LoginService.loginServerMap.put("1",this);
    }

    @Override
    public Result login(String userName, String password) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        //检查用户状态
        Result result=LoginHelper.checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //验证密码是否正确
        if(!userInfo.getPassword().equals(password)){
            //登录失败处理
            userInfo =LoginHelper.loginFail(userInfo);
            userDao.update(userInfo);
            return new Result("004","账户名或密码错误",null);
        }
        //密码正确后返回用户信息和登录的token
        result=LoginHelper.loginSuccess(userInfo);
        return result;
    }

}

验证码登录

/**
 * 验证码登录实现
 */
@Service("smsLoginService")
public class SMSLoginService implements LoginService {

    @Autowired
    private  userDao userDao;


    public SMSLoginService() {
        //把自己,和自己处理的loginType注册到缓存
       LoginService.loginServerMap.put("2",this);
    }


    @Override
    public Result login(String userName, String smsCode) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        //检查用户状态
        Result result=LoginHelper.checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //验证码是否正确
        String code=userDao.querySmsCode(userName);
        if(!code.equals(smsCode)){
            //登录失败处理
            LoginHelper.loginFail(userInfo);
            userDao.update(userInfo);
            return new Result("004","验证码错误",null);
        }
        //密码正确后返回用户信息和登录的token
        result=LoginHelper.loginSuccess(userInfo);
        return result;
    }
}

新增的手势密码登录

/**
 * 手势登录实现
 */
@Service("gGestureLoginService")
public class GestureLoginService implements  LoginService {

    @Autowired
    private  userDao userDao;


    public GestureLoginService() {
        //把自己,和自己处理的loginType注册到缓存
        LoginService.loginServerMap.put("3",this);
    }


    @Override
    public Result login(String userName, String gesture) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        //检查用户状态
        Result result=LoginHelper.checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //验手势密码是否正确
        if(!userInfo.getGesture().equals(gesture)){
            //登录失败处理
            userInfo=LoginHelper.loginFail(userInfo);
            userDao.update(userInfo);
            return new Result("004","手势密码错误",null);
        }
        //密码正确后返回用户信息和登录的token
        result=LoginHelper.loginSuccess(userInfo);
        return result;
    }

}

公共方法抽取类

public class LoginHelper {

    /**
     * 检查用户状态
     * @param userInfo
     * @return
     */
    public static  Result checkUserStatus(UserInfo userInfo) {
        if(userInfo==null){
            return new Result("001","用户不存在",null);
        }
        //检查用户是否被禁用了(0正常,1禁用)
        if(userInfo.getStatus()==1){
            return new Result("002","账号已被禁用",null);
        }
        //检查用户是否被锁定了。
        Date lockEndTime=userInfo.getLockEndTime();
        if(lockEndTime!=null&&lockEndTime.getTime()>System.currentTimeMillis()){
            return new Result("003","账户已锁定",null);
        }
        return null;
    }


    /**
     * 登录成功
     * @param userInfo
     * @return
     */
    public static Result loginSuccess(UserInfo userInfo) {
        Map map=new HashMap<>();
        map.put("userInfo",userInfo);
        map.put("token","生成的token信息");
        return new Result("200","成功",map);
    }


    /**
     * 登录失败
     * @param userInfo
     */
    public static UserInfo loginFail(UserInfo userInfo) {
        //验证码错误后,累计登录错误次数
        userInfo.setFailNumber(userInfo.getFailNumber()+1);
        //检查登录错误次数是否超过5次,超过则锁定账户30分钟。
        if(userInfo.getFailNumber()>5){
            Date lockTime= DateUtils.addMinutes(new Date(),30);
            userInfo.setLockEndTime(lockTime);
        }
        return userInfo;
    }
}

我们再回头看看经过策略模式优化后的代码和之前有什么区别,如果在这个代码的基础上再新增加别的登录方式比如:指纹登录、扫脸登录、眼膜登录、第三方登录........ 等等,我们是否还需要变更原来的代码,从上面的代码看我们是不需要对之前的代码进行任何修改的,只需要新增一个登录的实现,然后把自己注册到LoginService.loginServerMap 缓存里面去,就大功告成了,这是不是就完美符合了“对修改关闭,对扩展开放”设计原则,这也是使用策略模式的魅力。

第三次优化(增加模板模式)

经过策略模式的洗礼,我们的代码在不修改源代码的基础上已经具备了可扩展性,但是我们回头再仔细看代码时,我们还是会发现一些不完美的地方。我们发现在每个登录实现类里面其实还是有大量重复的逻辑,并且这些逻辑在每个登录方式里面的流程都一模一样,它们的流程都如下:

1、首先我们先查询用户信息。

2、验证用户状态。

3、根据登录类型验证对应的数据,返回是否登录成功。

4、登录成功则生成token返回数据,登录失败时则记录错误次数。

把整个登录流程定义下来后,我们发现不管是哪种登录方式整个流程都是这样走的,而且上面的流程1、2、4在每种登录方式中都是一样的,发现问题就已经解决问题的一大半了,因为有一种设计模式是专门定义流程,复用重复功能的模式叫“模板模式”。

模板模式的定义

意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

主要解决:定义流程,实现通用的方法,个性化的方法交给子类去实现。

何时使用:有一些通用的方法和固定的流程。

如何解决:将这些通用算法抽象出来。

关键代码:在抽象类实现,其他步骤在子类实现。

在登录功能上应用模板模式

根据模板模式的思路,我们可以把流程的定义,并且把1、3、4重复功能直接在抽象类里面实现,然后把流程2的功能交给具体的子类去实现,我们的登录实现可以直接继承这个抽象类,那么自然就拥有了1、2、4流程的功能了,自己需要实现的仅仅是流程3的功能,通过图形构建了一遍我们的思路后如下:

最有经过优化后的代码如下:

登录模板类

/**
 * 登录模板类
 */
public abstract class AbstractLogin implements LoginService {

    private userDao userDao;

    public AbstractLogin(userDao userDao) {
        this.userDao = userDao;
    }

    @Override
    public Result login(String userName, String content) {
        //查询用户信息
        UserInfo userInfo=userDao.queryByUserName(userName);
        //检查用户状态
        Result result=checkUserStatus(userInfo);
        if(result!=null){
            return result;
        }
        //登录验证
        result= doLogin(userInfo,content);
        //登录失败
        if(!result.getCode().equals("200")){
            loginFail(userInfo);
            return result;
        }
        //登录成功
        result=loginSuccess(userInfo);
        return result;
    }


    /**
     * 登录验证
     * @param userInfo
     * @return
     */
    public abstract Result doLogin(UserInfo userInfo,String content);


    /**
     * 检查用户状态
     * @param userInfo
     * @return
     */
    private   Result checkUserStatus(UserInfo userInfo) {
        if(userInfo==null){
            return new Result("001","用户不存在",null);
        }
        //检查用户是否被禁用了(0正常,1禁用)
        if(userInfo.getStatus()==1){
            return new Result("002","账号已被禁用",null);
        }
        //检查用户是否被锁定了。
        Date lockEndTime=userInfo.getLockEndTime();
        if(lockEndTime!=null&&lockEndTime.getTime()>System.currentTimeMillis()){
            return new Result("003","账户已锁定",null);
        }
        return null;
    }


    /**
     * 登录失败
     * @param userInfo
     */
    public  void loginFail(UserInfo userInfo) {
        //验证码错误后,累计登录错误次数
        userInfo.setFailNumber(userInfo.getFailNumber()+1);
        //检查登录错误次数是否超过5次,超过则锁定账户30分钟。
        if(userInfo.getFailNumber()>5){
            Date lockTime= DateUtils.addMinutes(new Date(),30);
            userInfo.setLockEndTime(lockTime);
        }
        userDao.update(userInfo);
    }


    /**
     * 登录成功
     * @param userInfo
     * @return
     */
    public static Result loginSuccess(UserInfo userInfo) {
        Map map=new HashMap<>();
        map.put("userInfo",userInfo);
        map.put("token","生成的token信息");
        return new Result("200","成功",map);
    }

}

密码登录

/**
 * 密码登录实现
 */
@Service("passwordLoinService")
public class PasswordLoinService extends   AbstractLogin {

    @Autowired
    public PasswordLoinService(userDao userDao) {
        super(userDao);
        LoginService.loginServerMap.put("1",this);
    }

    @Override
    public Result doLogin(UserInfo userInfo, String password) {
        //验证密码是否正确
        if(!userInfo.getPassword().equals(password)){
            return new Result("004","账户名或密码错误",null);
        }else{
            return new Result("200","登录成功",null);
        }
    }

}

短信验证码登录

/**
 * 验证码登录实现
 */
@Service("smsLoginService")
public class SMSLoginService extends AbstractLogin {

    private  userDao userDao;

    @Autowired
    public SMSLoginService(userDao userDao) {
        super(userDao);
        this.userDao=userDao;
        LoginHelper.loginServerMap.put("2",this);
    }


    @Override
    public Result doLogin(UserInfo  userInfo, String smsCode) {
        //验证码是否正确
        String code=userDao.querySmsCode(userInfo.getUserName());
        if(!code.equals(smsCode)){
            return new Result("004","验证码错误",null);
        }else{
            return new Result("200","登录成功",null);
        }

    }
}

手势登录

/**
 * 手势登录实现
 */
@Service("gGestureLoginService")
public class GestureLoginService extends   AbstractLogin {


    @Autowired
    public GestureLoginService(userDao userDao) {
        super(userDao);
        LoginService.loginServerMap.put("3",this);
    }


    @Override
    public Result doLogin(UserInfo userInfo,String geture) {
        //验手势密码是否正确
        if(!userInfo.getGesture().equals(geture)){
            return new Result("004","手势密码错误",null);
        }else{
            return new Result("200","登录成功",null);
        }
    }
}

代码经过了几次优化之后,我们会发现我们的代码不仅仅扩展性变得很强,新增其他的登录方式根本不会影响之前的功能,代码也都经过了合理的复用没有任何重复冗余的代码,每当我们需要新增一种新的登录方式时,我们只需要新增一个登录的实现类继承登录模板类后、把自己注册到LoginService.loginServerMap里面,新增一种登录方式只需要几行代码就可以搞定了。

如果你的登录代码经过了上面一番洗礼之后,我想你会乐于接受产品经理新的需求,因为我们的代码里面对扩展是开放的,再也不会陷入像以前那样牵一发而动全身的代码里面了,面对新的需求我们简简单单几行代码就能搞定,当然这里的代码省略了很多细节,往往真实的登录场景里业务要比这个复杂,但是这个思路却是通用的。

如果你通过这个案例已经理解了“策略模式”和“模板模式”,那么赶紧用起来吧,或者想想你们项目中哪些地方业务场景也和这个类似,那么也可以尝试用这个思路去优化一下,一旦用过一次了,那么必定你也已经完全掌握了这两个设计模式了。

本文转载自:https://zhuanlan.zhihu.com/p/157633600

原文地址:https://www.cnblogs.com/looyee/p/15002997.html