项目重构实践

本文探讨了重构的定义、时机、好处以及实际项目中的重构实践。重构是在不改变业务逻辑的基础上改进代码,确保单测的充分性至关重要。适当时机包括在开发新需求时遇到困难或发现现有代码可以优化。重构的好处包括提高代码可读性和可扩展性,遵循设计原则。文章介绍了参数校验、业务抽象、远程服务模板和灰度控制等方面的重构实践,通过具体的项目案例展示了如何在实际工作中实施重构。

重构的定义

我理解的重构是,在不改变业务逻辑的基础上,修改代码实现,让其具有更好的可读性、可扩展性。要保证不改变业务逻辑是前提,有可能一次重构把正常的业务逻辑改坏了,搞成了线上故障。怎样保障这个前提呢?那就是充分的单测。有了单测的保证,采用小步快跑的方式,逐步重构。每完成一个小的修改,就运行一次单测。逐步重构可以让你快速发现问题,当一次单测失败了,你就知道是这次小的改动导致了bug。如果进行了多处修改,再运行单测,此时定位bug是哪处修改导致的,将花费更多的时间。这是逐步单测带来的好处,让你快速定位bug。有一句话叫做,无单测不重构。因为没有了单测的保障,你没有信心保证本次的重构不会导致业务逻辑错误。但是,重构仅仅是为了让代码更优雅,不会直接带来产品功能的增量。甚至因为花时间重构导致占用了需求开发的时间,所以除了程序员其他角色往往不关心重构甚至抵制重构,这就导致了坚持重构的程序员往往成了孤勇者。

重构的时机

什么时候进行重构呢?有人认为重构与开发是冲突的。必须暂停所有进行中的需求迭代,让后投入一批人,基于某个版本进行代码重写。往往这样的重构会是灾难性的。可以参考软件行业永远不要做的事这篇文章。恰恰相反,重构是一个可以融入日常开发迭代的事,每一次需求迭代都是重构的时机。当你面对项目的代码准备开发一个新的需求了,实现的过程中,你发现基于现有的实现很难实现本次业务需求,或者即使可以实现业务需求,但是要写很多恶心的定制代码,或者目前的代码实现刚好适用于某个设计模式的场景,使用设计模式改造后,更简洁,更容易理解,日后更好修改。当遇到上述几种场景的时候,代表着是时候进行重构了。

重构的好处

为什么要重构呢?作为写过几年代码的同学,你肯定看到过一团乱麻的代码。修改这种代码的感觉,如履薄冰,达到了牵一发而动全身的地步。这种代码往往耦合度非常高,职责居多,既做了A事,又做了B事,还做了C事。当你想复用A逻辑的时候,你发现这段代码很难被单独执行,很难被复用。面向对象的语言,带来的一个好处就是提高了代码的复用性,降低了开发人员的理解成本。一个组件的使用者不需要了解使用组件的实现细节,只需要拿到对象的引用就可以使用组件提供的能力。当然,这种情况是有良好设计的情况下,才会发生的。有一个普遍存在的误区,我使用了面向对象的语言,所以我写的代码就是面向对象的代码。我见过的很多开发项目,虽然使用了Java,但是本质上是面向过程编程了,属于事务脚本范畴,跟面向对象相差甚远。一个简单的检验标准是,代码实现是否使用到了接口、继承、多态、封装这些语言特征。很多实现都是业务逻辑的罗列,缺乏合理的设计。我们都知道衡量一个软件质量的标准有一个叫做SOLID的标准,也就是设计原则。包括,开闭原则、单一职责原则、里氏替换原则等。符合这些标准的软件,软件质量肯定是高的。重构就是一种达到SOLID原则的手段。设计原则是目标,重构、设计模式、领域设计是具体的实现手段。

重构实践

说了这么多,都是比较虚的理论。在实际项目中怎么具体操作呢?下面就结合自己最近开发的项目,说下自己的理解。

项目背景

简单的讲就是目前业务功能依赖的服务A改成服务B。现状如图,后端提供了一批标准的服务,这些服务注册在网关中。基于接口定义有一批服务实现。本次项目会将业务领域层的服务实现进行重构,下面会介绍一下重构过程中的遇到的一些典型问题和自己的解决办法,提供给大家参考,期望达到抛砖引玉的作用。

现状

目标

参数校验

现有实现

这是一个对外服务的方法实现。我们在实现对外服务时,第一步往往要做参数校验。因为是对外提供的服务,调用方提供的入参五花八门,虽然会约定协议,但没法保证每个调用方都是严格按照协议传参。所以,对于入参的校验是不可或缺的。下面的参数校验是比较典型的实现方式,对必填参数就行校验,校验不通过就返回错误提示。如果这种校验逻辑比较少,是可以接受的。但是当我们提供了大量服务,每个服务都有这么一段逻辑。我们就要考虑下,是不是有统一的解决办法。

@Override
    public BaseResponse<Knowledge> getKnowledgeDetail(KnowledgeRequest request) {
        if (request == null) {
            return ResponseUtils.error(null, PaasResponseEnum.MISSING_PARAMETER, "Request");
        }
        if (StringUtils.isEmpty(request.getTenantId())) {
            return ResponseUtils.error(request.getRequestId(), PaasResponseEnum.MISSING_PARAMETER, "RobotCode");
        }
        if (request.getKnowledgeId() == null) {
            return ResponseUtils.error(request.getRequestId(), PaasResponseEnum.MISSING_PARAMETER, "KnowledgeId");
        }
        ...
     }   

解决方案

对于校验这块,java软件生态是有成熟的解决方案的。那就是java校验规范,validation-api。

jsr规范请求 JSR是Java Specification Requests的缩写,意思是Java 规范提案。 是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。

这套校验规范定义了,校验所需要的基本实体和接口。类似于servlet-api,委员会只定义规范,不提供实现。就像tomcat是基于servlet-api实现的servlet容器一样。hibernate-validator是对java参数校验规范的一种实现。

实践

在项目中引入如下依赖。如果是SpringBoot应用的话,SpringBoot检测到有hibernate-validator的依赖,就会进行自动装配,帮我们初始化好需要的bean。我们在业务代码中只要注入javax.validation.Validator实例就可以进行校验逻辑了。

<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-validator</artifactId>
</dependenc

所以上面的校验实现可以实现为下面这样。

第一步声明字段应该满足什么约束。我们使用注解规定tenantId和knowledgeId必须非空,并且knowledgeId必须大于等于1。

class KnowledgeRequest {
    @NotNull
    private String tenantId;
    
    @NotNull(groups = {SingleKnowledgeGroup.class, AddSolutionGroup.class})
    @Min(groups = AddSolutionGroup.class, value = 1)
    private Long knowledgeId;
}

第二步调用框架进行校验。我们上面定义的规则叫做约束,约束是分组的。不显式的指定分组,则默认归属于Default分组。在调用校验接口时,需要指明校验哪些分组的约束。分组是为了解决,同一个对象在不同场景下,需要执行不同的约束问题。

@Override
public BaseResponse<Knowledge> getKnowledgeDetail(KnowledgeRequest request) {
        BaseResponse<Knowledge> baseResponse = validateParams(request, Default.class, SingleKnowledgeGroup.class);
        if (baseResponse != null) {
            return baseResponse;
        }
        ...
   } 

可以把校验逻辑放在基类中,这样子类只要指明需要校验哪个对象的哪组约束就可以了。

protected <T> BaseResponse<T> validateParams(BaseRequest baseRequest, Class<?>... group) {
        if (baseRequest == null) {
            return ResponseUtils.error(null, PaasResponseEnum.MISSING_PARAMETER, "请求");
        }
        Set<ConstraintViolation<BaseRequest>> validate = validator.validate(baseRequest,
            (Class<?>[])ArrayUtils.add(group, Default.class));
        if (validate.size() > 0) {
            ConstraintViolation<BaseRequest> next = validate.iterator().next();
            String fieldName = next.getPropertyPath().toString();
            String message = next.getMessage();
            log.error("validation fail {} {}", fieldName, message);
            return ResponseUtils.error(baseRequest.getRequestId(), PaasResponseEnum.MISSING_PARAMETER, fieldName);
        }
        return null;
    }

业务抽象

现有实现

这是一个知识搜索的接口,它的职责是根据用户在页面上的查询条件,调用ES进行检索。知识和答案的数据存在不同的索引中。根据查询条件来决定是搜索知识的索引还是搜索答案的索引。搜索到知识后,再根据知识id,获取知识详情。

                return ResponseUtils.success(query.getRequestId(), null);
            }
            //转换为知识id
            questionIds = questionSearchResponse.getData().getPageData().getData().stream().map(question -> question.getQuestionId()).collect(Collectors.toList());
            //根据知识id获取知识详情
            return searchPageThenReturn(questionIds, query, tenantId, industryTenantId, null);
        }
​
        //基于答案进行搜索
        //分支B
        SolutionSearchRequest solutionSearchRequest = new SolutionSearchRequest();
        SolutionSearchQuery solutionSearchQuery = new SolutionSearchQuery();
        solutionSearchQuery.setTenantId(tenantId);
//        solutionSearchQuery.setStatus(com.alibaba.cxdc.knowledgecloud.qa.StatusEnum.ONLINE.getCode());
        if (!org.springframework.util.StringUtils.isEmpty(query.getSolutionKeyword())) {
            solutionSearchQuery.setPlainText(query.getSolutionKeyword());
        }
        if (!org.springframework.util.StringUtils.isEmpty(query.getSolutionAccurateKeyword())) {
            solutionSearchQuery.setSolutionKeywords(Arrays.asList(query.getSolutionAccurateKeyword()));
        }
        if (query.getItemId() != null) {
            solutionSearchQuery.setItemIds(Arrays.asList(query.getItemId()));
        }
        if (query.getItemCategoryId() != null) {
            solutionSearchQuery.setItemCatIds(Arrays.asList(query.getItemCategoryId()));
        }
        if (query.getActivityId() != null) {
            solutionSearchQuery.setActivityIds(Arrays.asList(query.getActivityId()));
        }
        if (query.getExtraInfo() != null) {
            ExtraInfo extraInfo = query.getExtraInfo();
            if (!CollectionUtils.isEmpty(extraInfo.getMultis())) {
                solutionSearchQuery.setMultis(extraInfo.getMultis());
            }
            if (!CollectionUtils.isEmpty(extraInfo.getOrderStatus())) {
                solutionSearchQuery.setSopStatus(QaHelper.handleStatus(extraInfo.getOrderStatus()));
            }
            if (!CollectionUtils.isEmpty(extraInfo.getTimeType())) {
                solutionSearchQuery.setTimeTypes(extraInfo.getTimeType());
            }
            if (!CollectionUtils.isEmpty(extraInfo.getLogisticsStatus())) {
                solutionSearchQuery.setLogisticsStatus(QaHelper.handleStatus(extraInfo.getLogisticsStatus()));
            }
            if (!CollectionUtils.isEmpty(extraInfo.getRefundStatus())) {
                solutionSearchQuery.setRefundStatus(QaHelper.handleStatus(extraInfo.getRefundStatus()));
            }
        }
        searchPage.setPageSize(10000);
        searchPage.setCurrentPage(1);
        solutionSearchRequest.setPage(searchPage);
        SolutionSearchCondition solutionSearchCondition = new SolutionSearchCondition();
        solutionSearchCondition.setCollapseByQuestionId(true);
        solutionSearchRequest.setCondition(solutionSearchCondition);
        solutionSearchRequest.setQuery(solutionSearchQuery);
        solutionSearchRequest.setFilter(searchFilter);
        
        //根据搜索条件查询到知识
        Result<SolutionSearchResponse> solutionSearchResponse = kCloudSolutionSearchService.searchSolution(new RequestContext(), solutionSearchRequest);
        if (!ResultUtils.checkResult(solutionSearchResponse) || solutionSearchResponse.getData().getPageData() == null || CollectionUtils.isEmpty(solutionSearchResponse.getData().getPageData().getData())) {
            log.info("kCloudSolutionSearchService#searchSolution error:query:{} request:{} response:{}", query, solutionSearchRequest, solutionSearchResponse);
            return ResponseUtils.success(query.getRequestId(), null);
        }
        
        //转换为知识id
        questionIds = solutionSearchResponse.getData().getPageData().getData().stream().map(solution -> solution.getQuestionId()).collect(Collectors.toList());
        log.info("solutionSearchResponseIds:{}-{}", questionIds, query);
​
        //根据知识id获取知识详情
        return searchPageThenReturn(questionIds, query, tenantId, industryTenantId, packageIds);
    }

逻辑分析

这段代码的问题在于,实现逻辑不清晰。对应代码作者来说,要做什么事,了然于心。但是对应代码的读者来说却未必。毕竟代码不仅仅要可以运行,更重要的是让人好理解,因为代码是写给人看的。我们可以分析下上面代码的逻辑,然后抽象出以下核心逻辑也可以说是核心模型。

1、查询到知识id 1-1 查询知识索引,获取知识id   1-2 查询答案索引,获取知识id 2、根据查询到的知识id获取知识详情

代码里面的分支A和分支B可以被抽象为一个组件。这个组件我们姑且叫它KnowledgeIdSearcher,它的职责是根据条件,查询到知识id。不过有两种查询方式,一种查询知识,一种查询答案。要做相同的事情,不过做的方式不同。就可以归纳为一个接口的两种实现。

实践

首先我们定义KnowledgeIdSearcher

public interface KnowledgeIdSearcher {
    Page<List<Long>> search(KnowledgeQuery query);
}

然后是两种实现方式。基于知识搜索

public class KnowledgeBasedKnowledgeIdSearcher extends BaseSearcher implements KnowledgeIdSearcher {
   @Override
    public Page<List<Long>> search(KnowledgeQuery query){
        业务逻辑...
    }
}

基于答案搜素

public class SolutionBasedKnowledgeIdSearcher extends BaseSearcher implements KnowledgeIdSearcher {
   @Override
    public Page<List<Long>> search(KnowledgeQuery query){
        业务逻辑...
    }
}

实现逻辑主体

@Override
    public BaseResponse<KnowledgePage> searchKnowledge(KnowledgeQuery query) {
        //根据条件,决定使用哪种搜索方式
        KnowledgeIdSearcher knowledgeIdSearcher = query.isSearchSolution() ? createSolutionBasedSearcher()
            : createKnowledgeBasedSearcher();
        //查询知识id
        Page<List<Long>> pageKnowledge = knowledgeIdSearcher.search(query);
        //根据知识ids查询知识详情
        List<com.alibaba.cxdc.kcplus.faq.core.api.knowledge.Knowledge> knowledges = batchFetchKnowledges(query,
            knowledgeIds, (t, u) -> knowledgeReadService.describeKnowledge(t, u));
        //做出参的转换
        List<Knowledge> mappedKnowledges = knowledgeMapper.map(knowledges);
        ...构造出参
    }

这种实现方式,方法体更短,一眼可以大概知道要怎么做这件事,整体逻辑比较清晰。不同的步骤封装在不同的组件中,也避免了一个方法职责混杂。当一个类或者方法职责混杂的情况下,它就很难甚至无法被复用。

远程服务模板

现有实现

在微服务业务应用中依赖了大量的rpc服务。发起一个rpc服务调用的步骤大概是这个样子。

public BaseResponse queryKnowledgeDetail(BaseRequest baseRequest) {
        //入参映射
        KcBaseRequest kcBaseRequest = mapToKcBaseRequest(baseRequest);
        //入参映射
        KnowledgeReadRequest readRequest = mapToKnowledgeReadRequest(baseRequest);
        Result<Knowledge> knowledgeResult = null;
        try {
            //接口调用
            knowledgeResult = knowledgeReadService.describeKnowledge(kcBaseRequest, readRequest);
        } catch (Exception e) {
            //处理异常
        }
        //返回值校验
        if (knowledgeResult != null && !knowledgeResult.isSuccess()) {
            return null;
        }
        //出参转换
        return mapToSingleKnowledgeResponse(knowledgeResult);
    }

因为本次重构工作量很大,需要调用30+外部服务。如果每个服务都写一遍这样的逻辑,那工作量巨大。最大的问题是,这样实现存在大量重复。重复是一种坏味道,应该被消除。重复意味着难以维护,当你想加或者修改一段通用的逻辑,要改30多处。我觉得没有哪个程序员原因干这种活,虽然程序员的工作本质上也是体力活。但,没人愿意把时间和精力花在重复代码的修改上。

逻辑分析

日常开发过程中,调用外部服务时,往往有这么几个步骤。这几个步骤往往是比较固定的。

1、组装入参
2、调用接口
3、返回值校验
4、出参转换
5、处理异常

以上这几个步骤是固定的,所以我尝试着将固定的步骤抽取为通用的逻辑。但是面临着一个问题,不同的服务调用入参都不尽相同,怎么才能抽象成统一的呢?这个时候就需要用到java中的泛型和函数式编程。泛型是类型的参数化。函数式编程是对方法的抽象。

实践

我这次重构的项目所依赖的服务都是两个入参的,所以可以将参数的获取抽象为两个Supplier<T>,Supplier表示没有入参,但有返回值的方法。使用lamda表达式构造Supplier时,可以使用表达式所在方法体中的变量。然后函数调用被抽象为一个BiFunction,它是有两个入参,一个人返回值的方法。出参的转换被抽象为一个有一个入参和一个返回值的函数。

public <T, U, R, B> B doInvoke(Supplier<T> supplier1, Supplier<U> supplier2, BiFunction<T, U, R> invokeFunc,
        Function<R, B> responseMapFunction)
        throws ProviderBizException {
        R apply = doInvoke(supplier1, supplier2, invokeFunc);
        B response = responseMapFunction.apply(apply);
        processDebug(apply, response);
        return response;
    }

这里两个方法的区别在于,一个可以具有出参映射的功能,一个只返回原始接口出参。这样做的原因是,有一种调用场景要求获取原始服务出参,不需要做转换。这里也体现了职责单一的好处,这个方法只做原始的服务请求,可以被不需要出参转换的调用方复用也可以被需要出参转换的调用方复用。至于是否做出参转换也由调用方决定。

protected <T, U, R> R doInvoke(Supplier<T> supplier1, Supplier<U> supplier2, BiFunction<T, U, R> invokeFunc)
        throws ProviderBizException {
        T t = null;
        U u = null;
        try {
            t = supplier1.get();
            u = supplier2.get();
            R apply = invokeFunc.apply(t, u);
            Result result = (Result)apply;
            DebugResult debugResult = new DebugResult(result);
            debugResult.put("param1", t);
            debugResult.put("param2", u);
            if (result == null || !result.isSuccess()) {
                String resultString = (result == null ? "null" : result.toString());
                throw new ProviderBizException("operation fail, " + resultString, result);
            }
            return (R)debugResult;
        } catch (Exception e) {
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("param1", t);
            jsonObject.put("param2", u);
            log.error("invokeFunc.apply error, " + jsonObject.toJSONString(), e);
            throw e;
        }
    }

灰度控制

业务背景介绍

imp位于业务领域层,实现逻辑中依赖数据服务层提供的远程服务。现在要将现有的一套服务替换成另外一套服务实现,而且要保证产品逻辑的不变。

一种显而易见的实现方式是基于imp1进行改造。将依赖的各种service替换为新的依赖。这种改造方式的缺点是,改动点非常多,而且不容易做灰度控制。业务上希望做平稳切换,不影响业务体验。一旦改造老逻辑出现bug,很难回滚到之前的逻辑。还有一个问题是改动点一多,就容易遗漏,也很难做统一的管理。还有一种方案是,对原有实现不做任何改动,基于上层的接口进行全新的实现。这样更容易做到灰度控制,也可以较少的顾忌原有实现的历史包袱。

业务分析

现状是业务逻辑实现A直接对外暴露rpc服务。很容易想到的路由实现方式是,在业务实现A通过灰度判断决定执行实现A的逻辑还是执行实现B的逻辑。但这种方式最大的问题是,路由逻辑侵入业务代码,而且要记住有30+接口要改。这时候我们又闻到了坏味道,重复。所以,我的实现方案是,不让实现A直接暴露rpc服务,改由于Facade层暴露服务。使用aop切面拦截所有请求,将请求代理给ServcieProxy处理。因为读写的处理逻辑不同,所以ServiceProxy又分为ReadServiceProxy和WriteServcieProxy。ServiceProxy根据灰度配置,决定使用哪个业务逻辑实现,通过反射发起最终的方法调用。

实践

拦截所有对Facade层的调用,获取反射所需的基本参数。根据读写服务,路由到具体的ServiceProxy。

@Around("pointCut()")
    public Object intercept(ProceedingJoinPoint joinPoint) {
        long start = System.currentTimeMillis();
        Object[] args = joinPoint.getArgs();
        Signature signature = joinPoint.getSignature();
        String methodName = signature.getName();
        Class<?> userClass = ClassUtils.getUserClass(joinPoint.getTarget());
        String clazzName = userClass.getName();
        Class<?>[] interfaces = userClass.getInterfaces();
        Class itf = interfaces[0];
        String iftName = itf.getName();
        ServiceProxy serviceProxy;
        if (clazzName.contains("Read")) {
            serviceProxy = readServiceProxy;
        } else if (clazzName.contains("Write")) {
            serviceProxy = writeServiceProxy;
        } else {
            throw new IllegalStateException(String.format("%s not support", clazzName));
        }
        ProxyParams request = new ProxyParams(iftName, methodName, args);
        Object response = null;
        Exception exp = null;
        try {
            initMDC();
            log.info("receive request");
​
            response = serviceProxy.execute(request);
        } catch (Exception exception) {
            log.error("unhandled exception", exception);
            response = ResponseUtils.generateExceptionResponse(null, exception);
            exp = exception;
        } finally {
            processDebug(start, request, response, exp);
            MDC.clear();
        }
        return response;
    }

ServiceInvoker负责获取反射所需的Object、Method、Args参数

public class ServiceInvoker {
  protected MethodInvoker route(ProxyParams proxyParams, boolean useLocalImpl, boolean write) {
        String itfName = proxyParams.getItfName();
        Class<?> iftClass = null;
        try {
            iftClass = ClassUtils.forName(itfName, ClassUtils.getDefaultClassLoader());
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
​
        Object objToUse = useLocalImpl ? getLocalImpl(applicationContext, iftClass) : getPlatformImpl(
            applicationContext,
            iftClass);
        return (write && useLocalImpl) ? new ReplicationMethodInvoker(proxyParams, objToUse, iftClass, producer)
            : new MethodInvoker(proxyParams, objToUse, iftClass);
    }
}

MehtodInvoker负责发起最终的调用。至于为什么MethodInover这个组件,因为如果是写服务的写到了数据服务层A的话,还需要将数据复制一份到数据服务层B,主要是为了双写,保证灰度期间两边数据保持同步。此时,MethodInvoker就不仅仅是本地反射调用这么简单了。ReplicationMethodInvoker继承了MethodInvoker,它扩展了MethodInvoker的功能,在将数据写入数据服务层A之后,然后将数据同步一份到数据服务层B。

public class MethodInvoker {
​
    protected Object target;
    protected Object[] args;
    protected Method method;
    protected ProxyParams proxyParams;
​
​
    public MethodInvoker(ProxyParams proxyParams, Object target, Class<?> itfClass){
        this.proxyParams = proxyParams;
        this.args = proxyParams.getArgs();
        String methodName = proxyParams.getMethodName();
        this.method = ClassUtils.getMethod(itfClass, methodName, (Class<?>[])null);
        this.target = target;
    }
​
    public Object invoke() {
        try {
            return method.invoke(target, args);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        } catch (InvocationTargetException e) {
            throw new RuntimeException(e);
        }
    }
}

总结

实现一个业务功能往往有多种方案,其中最简单的方案是最好的。简单意味着容易理解,容易修改,容易测试,并且修改时不容易出错。把复杂业务逻辑变成复杂代码实现,甚至更复杂代码实现,是不好的。把复杂业务变成简单实现才是好的。好的设计就是将复杂的大问题逐层拆分为小问题,随着逐层的拆解,复杂的逻辑分散在层次结构和职责明确的组件中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值