動態生成簡約MVC請求介面|拋棄一切註解減少重複勞動吧

地藏Kelvin發表於2020-04-07

背景

目前建立一個後端請求介面給別人提供服務,無論是使用SpringMVC方式註解,還是使用SpringCloud的Feign註解,都是需要填寫好@RequestMap、@Controller、@Pathvariable等註解和引數。每個介面都需要重複的勞動,非常繁瑣。特別是服務治理框架的介面層不是springmvc,而都是通過TCP連線來做RPC通訊的介面,這樣的介面除錯起來比較麻煩,測試人員也不能感知介面引數,壓力測試的時候沒得使用JMETER方便。

目的

為了解放雙手,讓後端服務開發人員提供介面給別人時,只需要更關注邏輯。減少開發人員關注框架內容,減少關注每個@註解上的引數資訊,不用再校驗path是否已經被使用過。無須再感知SpringMVC或者Feign的存在。

我們統一做處理,把類名和方法名來做為請求介面url,不再顯式宣告url,預設POST請求、返回為JSON形式,請求引數支援@RequestBody、@RequestParam。

前置瞭解

Spring的鉤子類、鉤子方法

先看看簡約到什麼程度

@Contract
public interface UserContract {
       
    User getUserBody(User user);
}
複製程式碼
@Component
public class UserContractImpl implements UserContract {

    @Override
    public User getUserBody(User user11) {
        user11.setAge(123);
        return user11;
    }

}

複製程式碼

上述程式碼已生成的功能:

  1. url為 /UserContract/getUserBody的uri,
  2. 請求方法為POST
  3. 並且請求方式支援body方式提交user11物件
  4. 如果引數是基本型別的話預設是作為@RequestParam方式請求
  5. 返回方式為JSON
  6. 前端同事一說url是啥,就能定位在程式碼的哪個地方了

大家看,是不是不用再填寫任何的MVC、Feign註解了!!!!!

  1. 只需要使用@Contract註解,我們就會生成好一個類下所有方法的POST請求介面,並對映到對應方法。
  2. 讓開發人員只需要關注請求介面內邏輯,不再需要關注Controller如何生成。 程式碼一個MVC註解都沒有,對mvc介面生成無感知。
  3. 不嵌入實體類構建Bean過程。
  4. 相較正常的@Controller類,少寫@RequestMapping 等註解和上面的引數,少寫@RequestBody、少寫@RequestBody等引數解析方式。這些都不用再顯式填寫。只需要新增我們自定義註解,並在服務啟動時的動態生成簡約MVC完成。

使用場景

如果你的請求介面框架通過封裝RPC,底層不是springMVC,但又想增添MVC介面。

如果你的請求介面框架通過封裝RPC,底層不是springMVC,但又想提供給前端HTML使用。

如果你的請求介面框架通過封裝RPC,底層不是springMVC,但又想提供給測試人員方便閱讀,也方便用JMETER做壓力測試。

如果你的介面是Feign或者已經是springMVC,但是還在填寫url、path、請求method、引數解析方式、每次都要核對ur有沒有重複使用等繁瑣工作,可以放下這些操作了。

需求

  1. 只建立mvc的url與實現類的方法的關聯關係,不為實現類建立bean物件入容器,只關注MVC層面,不耦合其他層面的功能。
  2. 支援POST請求
  3. 類名和方法名拼接成為uri
  4. 請求引數支援@RequestParam,@RequestBody
  5. 返回資料為JSON
  6. 基於springboot

Previously

先看看原生MVC如何繫結URL和方法

consice.png

我們自己的實現主要處理第二步,注入我們自己的RequestMappingHandler。然後做第6、7步重寫,讓找@Controller的方法改為找@Contract,最後重寫處理url生成的方法。

實現

1. 啟動方式

首先實現啟動方式,使用下述註解放在在Springboot服務啟動類上,標明請求介面的實現類程式碼在哪個路徑。然後通過@Import(ContractAutoHandlerRegisterConfiguration.class) 在服務啟動時,新增url和類的關聯關係。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(ContractAutoHandlerRegisterConfiguration.class)
public @interface EnableContractConciseMvcRegister {

    /**
     * Contract 註解的請求包掃描路徑
     * @return
     */
    String[] basePackages() default {};

}
複製程式碼

2. import載入負責url和方法關聯關係處理的類

利用ImportBeanDefinitionRegistrar ,就會在@import時觸發邏輯,讓類BeanDefinition註冊到容器中。

public class ContractAutoHandlerRegisterConfiguration implements ImportBeanDefinitionRegistrar {

    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        log.info("開始註冊MVC對映關係");
        Map<String, Object> defaultAttrs = metadata
                .getAnnotationAttributes(EnableContractConciseMvcRegister.class.getName(), true);

        if (defaultAttrs == null ||  !defaultAttrs.containsKey("basePackages"))
            throw new IllegalArgumentException("basePackages not found");

        //獲取掃描包路徑
        Set<String> basePackages = getBasePackages(metadata);
        //生成BeanDefinition並註冊到容器中
        BeanDefinitionBuilder mappingBuilder = BeanDefinitionBuilder
                .genericBeanDefinition(ContractAutoHandlerRegisterHandlerMapping.class);
        mappingBuilder.addConstructorArgValue(basePackages);
        registry.registerBeanDefinition("contractAutoHandlerRegisterHandlerMapping", mappingBuilder.getBeanDefinition());

        BeanDefinitionBuilder processBuilder = BeanDefinitionBuilder.genericBeanDefinition(ContractReturnValueWebMvcConfigurer.class);
        registry.registerBeanDefinition("contractReturnValueWebMvcConfigurer", processBuilder.getBeanDefinition());
        log.info("結束註冊MVC對映關係");

    }
}

複製程式碼
  1. 利用Import形式registerBeanDefinitions時注入容器。
  2. 其中重要的只有ContractAutoHandlerRegisterHandlerMapping,ContractReturnValueWebMvcConfigurer。 ContractAutoHandlerRegisterHandlerMapping ,負責url與實現類(如UserContractImpl)方法的關聯關係。 ContractReturnValueWebMvcConfigurer,處理請求引數解析和方法返回資料轉換。

這裡利用註解和ImportBeanDefinitionRegistrar 實現了需求6 支援springboot容器。

3. 方法與URL對映

建立ContractAutoHandlerRegisterHandlerMapping繼承RequestMappingHandlerMapping。 重寫幾個比較重要的方法,其中一個是isHandler。

/**
     * 判斷是否符合觸發自定義註解的實現類方法
     */
    @Override
    protected boolean isHandler(Class<?> beanType) {
        // 註解了 @Contract 的介面, 並且是這個介面的實現類

        // 傳進來的可能是介面,比如 FactoryBean 的邏輯
        if (beanType.isInterface())
            return false;

        // 是否是Contract的代理類,如果是則不支援
        if (ClassUtil.isContractTargetClass(beanType))
            return false;

        // 是否在包範圍內,如果不在則不支援
        if (!isPackageInScope(beanType))
            return false;

        // 是否有標註了 @Contract 的介面
        Class<?> contractMarkClass = ClassUtil.getContractMarkClass(beanType);
        return contractMarkClass != null;
    }
複製程式碼

繼承這個類重寫這個方法的主要原因是

  1. 經過上面第一步已經把這個關聯關係放入容器中後,啟動SpringMVC註冊時,上述RequestMappingHandlerMapping這個類有繼承InitializingBean介面,就是通過這個***InitializingBean的afterPropertiesSet方法***執行後續的邏輯,這個是入口的關鍵,這個就是告訴等bean都構建完成後初始工作完成後處理的工作方法。(如流程圖第5步)
  2. springMVC原生RequestMappingHandlerMapping的afterPropertiesSet 這個時候會掃你工程程式碼裡所有類,並且會觸發我們自定義的ContractAutoHandlerRegisterHandlerMapping上述的isHandler方法
  3. 這個isHandler方法就需要我們去判斷,掃到的這個類是否符合建立mvc介面的類。
  4. 我們繼承了RequestMappingHandlerMapping,就可以自定義判斷的邏輯。判斷的邏輯就是這個class位元組碼是個類,不是interface,並且這個類上面必須有implement了一個interface,而且這個interface需要有@Contract註解(這個類沒有貼程式碼,就是自定義普通的註解,寫個名字就好了)
  5. 這樣就可以標記這是我們需要動態建立簡約MVC的類,這個類下的所有方法,都會被建立springMVC請求介面,那些被標記需要建立MVC的類就如前面樣例的***UserContractImpl***。

3. 如何動態建立MVC介面(關鍵點)

在ContractAutoHandlerRegisterHandlerMapping我們這個自定義類下,重寫getMappingForMethod這個方法,這個方法就是用來生成介面的URL,我們要有自己的方式所以要重寫。

因為當經過上一節,邏輯找到你程式碼工程下符合建立簡約MVC的類後,如找到UserContractImpl後,ContractAutoHandlerRegisterHandlerMapping的父類RequestMappingHandlerMapping邏輯會去找到UserContractImpl所有方法並進行建立url,然後繫結方法和url關係。(如流程圖的第7~9步)

@Override
    protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
        Class<?> contractMarkClass = ClassUtil.getContractMarkClass(handlerType);
        try {
            // 查詢到原始介面的方法,獲取其註解解析為 requestMappingInfo
            Method originalMethod = contractMarkClass.getMethod(method.getName(), method.getParameterTypes());
            RequestMappingInfo info = buildRequestMappingByMethod(originalMethod);

            if (info != null) {
                RequestMappingInfo typeInfo = buildRequestMappingByClass(contractMarkClass);
                if (typeInfo != null)
                    info = typeInfo.combine(info);
            }
            return info;
        } catch (NoSuchMethodException ex) {
            return null;
        }
    }

    private RequestMappingInfo buildRequestMappingByClass(Class<?> contractMarkClass) {

        String simpleName = contractMarkClass.getSimpleName();
        String[] paths = new String[] { simpleName };
        RequestMappingInfo.Builder builder = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(paths));

        // 通過反射獲得 config
        if (!isGetSupperClassConfig) {
            BuilderConfiguration config = getConfig();
            this.mappingInfoBuilderConfig = config;
        }

        if (this.mappingInfoBuilderConfig != null)
            return builder.options(this.mappingInfoBuilderConfig).build();
        else
            return builder.build();
    }

    private RequestMappingInfo buildRequestMappingByMethod(Method originalMethod) {
        String name = originalMethod.getName();
        String[] paths = new String[] { name };
        // 用名字作為url
        // post形式
        // json請求
        RequestMappingInfo.Builder builder = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(paths))
                .methods(RequestMethod.POST);
//                .params(requestMapping.params())
//                .headers(requestMapping.headers())
//                .consumes(MediaType.APPLICATION_JSON_VALUE)
//                .produces(MediaType.APPLICATION_JSON_VALUE)
//                .mappingName(name);
        return builder.options(this.getConfig()).build();
    }

    RequestMappingInfo.BuilderConfiguration getConfig() {
        Field field = null;
        RequestMappingInfo.BuilderConfiguration configChild = null;
        try {
            field = RequestMappingHandlerMapping.class.getDeclaredField("config");
            field.setAccessible(true);
            configChild = (RequestMappingInfo.BuilderConfiguration) field.get(this);
        } catch (IllegalArgumentException | IllegalAccessException e) {
            log.error(e.getMessage(),e);
        } catch (NoSuchFieldException | SecurityException e) {
            log.error(e.getMessage(),e);
        }
        return configChild;
    }
複製程式碼
  1. getMappingForMethod這個方法就是為了,處理實現類UserContractImpl下所有方法的url,得到url後會處理繫結關係到MVC的容器中。後續請求進來了,就會從這個MVC的容器map中根據url為key,找到value,value就是實現類的方法。
  2. getMappingForMethod裡的自己定義的buildRequestMappingByClass這個方法就是解析類名,我們的邏輯就是把類名作為介面uri的第一部分。如:/UserContract
  3. 自定義的buildRequestMappingByMethod就是處理方法,把方法名作為uri的第二部分,如/getUser。並且在這裡設定了為post作為請求方式.

這裡完成了需求3:類名和方法名拼接成為uri、需求2 POST請求方式

  1. 鑑於springmvc請求介面進來時,即使我們介面方法getUser的引數沒有註解,都會預設使用@RequestParam通過引數名字來對映,請求介面的引數。
  2. 如果是有成員變數的類物件,springmvc也會預設成@RequestBody來處理

這裡完成了需求4 請求引數支援@RequestParam,@RequestBody

4. 處理請求介面返回

之前第一步註冊的ContractReturnValueWebMvcConfigurer,就是做引數與返回處理。

public class ContractReturnValueWebMvcConfigurer implements BeanFactoryAware, InitializingBean {

    private WebMvcConfigurationSupport webMvcConfigurationSupport;
    private ConfigurableBeanFactory beanFactory;

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        if (beanFactory instanceof ConfigurableBeanFactory) {
            this.beanFactory = (ConfigurableBeanFactory) beanFactory;
            this.webMvcConfigurationSupport = beanFactory.getBean(WebMvcConfigurationSupport.class);
        }
    }

    public void afterPropertiesSet() throws Exception {

        try {
            Class<WebMvcConfigurationSupport> configurationSupportClass = WebMvcConfigurationSupport.class;
            List<HttpMessageConverter<?>> messageConverters = ClassUtil.invokeNoParameterMethod(configurationSupportClass, webMvcConfigurationSupport, "getMessageConverters");
            List<HandlerMethodReturnValueHandler> returnValueHandlers = ClassUtil.invokeNoParameterMethod(configurationSupportClass, webMvcConfigurationSupport, "getReturnValueHandlers");
            List<HandlerMethodArgumentResolver> argumentResolverHandlers = ClassUtil.invokeNoParameterMethod(configurationSupportClass, webMvcConfigurationSupport, "getArgumentResolvers");

            //只要匹配@Contract的方法,並將所有返回值都當作 @ResponseBody 註解進行處理  
            returnValueHandlers.add(new ContractRequestResponseBodyMethodProcessor(messageConverters));
}
複製程式碼

利用InitializingBean把WebMvcConfigurationSupport拿出來。對有自定義註解@Contract的interface的方法才會有特殊處理,這些方法都會使用@ResponseBody返回,就不用再在實現類的方法寫@ResponseBody了

這裡完成需求4 支援@ResponseBody

使用與測試

  1. 前面樣例的UserContractImpl已經寫了,只需要注意在UserContractImpl的interface(UserContract)上填@Contract。請求介面的程式碼類就不重複貼了。
  2. 現在編寫springboot啟動類,注意basePackages 為請求介面的實現類的包路徑。
@Configuration
@EnableAutoConfiguration 
@ComponentScan
@SpringBootApplication
@EnableContractConciseMvcRegister(basePackages = "com.dizang.concise.mvc.controller.impl")
public class ConsicesMvcApplication {
    
	public static void main(String[] args) throws Exception {
		SpringApplication.run(ConsicesMvcApplication.class, args);
	}

}
複製程式碼
  1. 啟動後,開啟swagger-ui.html
    image.png

總結

到目前為止,我們沒有在工程程式碼中使用springmvc註解,也能生成介面對映關係了。 這樣大家以後就再也不用寫SpringMVC的註解也能使用SpringMVC了,如果你公司框架預設是tcp連線的RPC介面,只要使用了這種方式,就可以自己本地除錯,不用再編寫一個RPC客戶端來訪問自己的介面。使用Swagger除錯又比較方便,而且測試同時也能看到請求引數,也可以對其做JMETER壓力測試。 不過程式碼都有一個問題,就是做法越統一,約束就越多。想自由,就約束少。所以我們這個框架,就只能用POST請求,並且ResponseBody來返回,就不適合要跳轉重定向頁面的那種,也不支援@PathVariable的引數解析方式,沒那麼RestFul風格(但可以把GET POST方式更改為用int值放在請求引數裡),但是支援@RequestParam和@RequestBody形式,我覺得也是足夠了。

程式碼樣例

gitee.com/kelvin-cai/…


歡迎關注公眾號,文章更快一步

我的公眾號 :地藏思維

動態生成簡約MVC請求介面|拋棄一切註解減少重複勞動吧

掘金:地藏Kelvin

簡書:地藏Kelvin

我的Gitee: 地藏Kelvin gitee.com/kelvin-cai

相關文章