zoukankan      html  css  js  c++  java
  • 浅析 Spring 异常处理

    如果你去面试,面试官问你 Spring 异常处理时,想必你一定能回答上,“如果某个 Controller 有 @ExceptionHandler 注解的方法,就走这个局部异常处理;没有的话就走那个 @ControllerAdvice 类中 @ExceptionHandler 修饰的方法。”面试官说:“嗯,没毛病非常正确,但是在多问一句,Spring 是如何实现的呢? 啥?忙于业务开发没思考过?额,抱歉,我们这个岗位不适合你~”

    我们先假设有一下两个接口,然后逐步揭开 Spring 异常处理的神秘面纱。相关版本:JDK8、Spring Boot 2.1.5.RELEASE、Spring 5.1.7.RELEASE

    @RestController
    public class MockHealth {
        /**
         * 测试缺少 userId 会产生的异常情况,会导致 Http 响应码 400
         * @param userId
         * @return
         */
        @GetMapping("/query")
        public String query(@RequestParam("userId") String userId) {
            return "userId = " + userId;
        }
    
    }
    
    @RestController
    public class TestController {
        /**
         * 测试全局异常和 Controller 内部的 ExceptionHandler
         * @param num
         * @return
         */
        @GetMapping("/divide-zero")
        public GenericResponse<Integer> divide(Integer num) {
            return GenericResponse.success(num/0);
        }
    }    
    

    在我们没有任何异常处理的情况下,尝试访问这两个接口,看看会得到什么结果。注意这里只演示异常处理情况,第一个 query 接口我们不传任何参数。
    query 接口不传参数divide-zero 接口
    再看后端日志打印输出了啥

    2019-11-30 16:53:26.853  WARN 67187 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required String parameter 'userId' is not present]
    2019-11-30 17:04:04.056 ERROR 67187 --- [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ArithmeticException: / by zero] with root cause
    
    java.lang.ArithmeticException: / by zero
    	at com.zst.provider.controller.TestController.divide(TestController.java:88)
    	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    	at java.lang.reflect.Method.invoke(Method.java:498)
    	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190)
    	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138)
    	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:104)
    	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:892)
    	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797)
    	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1039)
    	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:942)
    	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1005)
    	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:897)
    	at javax.servlet.http.HttpServlet.service(HttpServlet.java:634)
    	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:882)
    	at javax.servlet.http.HttpServlet.service(HttpServlet.java:741)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at com.alibaba.druid.support.http.WebStatFilter.doFilter(WebStatFilter.java:123)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:99)
    	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:92)
    	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:93)
    	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:200)
    	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107)
    	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
    	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
    	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:200)
    	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)
    	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:490)
    	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)
    	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
    	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
    	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)
    	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:408)
    	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:66)
    	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:836)
    	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1747)
    	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
    	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
    	at java.lang.Thread.run(Thread.java:748)
    

    先简单分析下,第一个 /query 接口请求参数 userId 是必传的,而我们没有传这个参数,导致响应码是 400。并且我们也可以第一行日志看出来,产生的 MissingServletRequestParameterException 异常被 DefaultHandlerExceptionResolver 类处理了。 第二个 /divide-zero 接口我们故意产生了一个算数异常。

    我们试着在 MockHealth 类中添加局部异常处理,同时也添加一个全局异常处理类 —— GlobalExceptionHandler,TestController 类则不发送任何变化,代码更新如下:

    @Slf4j
    @ControllerAdvice
    public class GlobalExceptionHandler {
    
        /**
         * https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ExceptionHandler.html
         * @param httpServletRequest
         * @param httpServletResponse
         * @param e
         * @return
         */
        @ResponseBody
        @ExceptionHandler(Throwable.class)
        public GenericResponse errorHandler(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                            Exception e) {
            int responseCode = httpServletResponse.getStatus();
            log.error("Unhandled Exception! url is {}, response code is {}, msg is {}", httpServletRequest.getRequestURI(), responseCode, e.getMessage(), e);
            return GenericResponse.failed("服务器内部异常!from [" + this.getClass().getCanonicalName() + "]");
        }
    
    }
    
    @Slf4j
    @RestController
    public class MockHealth {
    
        @ExceptionHandler(Throwable.class)
        public GenericResponse errorHandler(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                            Exception e) {
            int responseCode = httpServletResponse.getStatus();
            log.error("Unhandled Exception! url is {}, response code is {}, msg is {}", httpServletRequest.getRequestURI(), responseCode, e.getMessage(), e);
            return GenericResponse.failed("服务器内部异常!from [" + this.getClass().getCanonicalName() + "]");
        }
        
        /**
         * 测试缺少 userId 会产生的异常情况,会导致 Http 响应码 400
         * @param userId
         * @return
         */
        @GetMapping("/query")
        public String query(@RequestParam("userId") String userId) {
            return "userId = " + userId;
        }
    
    }
    

    仍旧分别调用 querydivide-zero 接口,观察局部异常和全局异常处理两种情况。根据我们经验得知 query 接口产生的异常由其所在类中的局部异常处理方法捕获,而 divide-zero 接口抛出的异常则交给全局异常类 GlobalExceptionHandler 处理,返回结果也正是我们预料的那样。在这里插入图片描述
    在这里插入图片描述
    我们通过上面几步验证了之前的说法,现在让我们一起来看 Spring 是如何做到的。

    Spring HandlerExceptionResolver

    先不用考虑什么是 HandlerExceptionResolver,我们就从第一次测试,产生的第一行异常日志开始下手,在 DispatcherServlet#processDispatchResult 打断点,看看这个日志是在怎么出来的,我们可以看到 MissingServletRequestParameterException 这个异常。很显然它不是 ModelAndViewDefiningException,继续往下走。
    在这里插入图片描述
    流程执行到 processHandlerException 方法,这里出现了日志中的 DefaultHandlerExceptionResolver 类。
    在这里插入图片描述
    我们重点看 HandlerExceptionResolverComposite 这个类(额,因为 DefaultErrorAttributes 确实也没干啥),继续往下调试就到了 HandlerExceptionResolverComposite#resolveException如果 resolvers 中有一个类返回了 ModelAndView,就不再往后遍历了。
    在这里插入图片描述
    到这里我们需要重点分析的几个类都已经找到了,先暂时放一下,回头来看一眼 HandlerExceptionResolver 接口,因为上面这几个类都实现了该接口,这个接口里面就只有一个方法。从注释可了解到,HandlerExceptionResolver 接口的实现类,都用来处理在映射或程序执行过程中产生的异常。 Spring 尿性就是一个功能,管他三七二十一先给你来个接口,再来个抽象类稍微意思意思,最后再整几个实际干活儿的类,整一套下来,就击败了不少喜欢刨根问底抛 Spring 源码的人。看破不说破~

    /**
     * Interface to be implemented by objects that can resolve exceptions thrown during
     * handler mapping or execution, in the typical case to error views. Implementors are
     * typically registered as beans in the application context.
     *
     * <p>Error views are analogous to JSP error pages but can be used with any kind of
     * exception including any checked exception, with potentially fine-grained mappings for
     * specific handlers.
     *
     * @since 22.11.2003
     */
    public interface HandlerExceptionResolver {
    
    	@Nullable
    	ModelAndView resolveException(
    			HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
    
    }
    

    ExceptionHandlerExceptionResolver —— 全局异常与局部异常处理的关键

    Spring 通过封装继承,最终上面 HandlerExceptionResolver#resolveException(request, response, handler, ex) 首先调用了 ExceptionHandlerExceptionResolver 类的 doResolveHandlerMethodException(request, response, handler, ex) 方法。我们看这个方法干啥了,把方法中的异常处理逻辑干掉,只看主要流程,精简后代码如下:

    	/**
    	 * Find an {@code @ExceptionHandler} method and invoke it to handle the raised exception.
    	 */
    	@Override
    	@Nullable
    	protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
    			HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) {
    		ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
    		if (exceptionHandlerMethod == null) {
    			return null;
    		}
    		
    		ServletWebRequest webRequest = new ServletWebRequest(request, response);
    		ModelAndViewContainer mavContainer = new ModelAndViewContainer();
    		try {
    			Throwable cause = exception.getCause();
    			if (cause != null) {
    				// Expose cause as provided argument as well
    				exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, exception, cause, handlerMethod);
    			}
    			else {
    				// Otherwise, just the given exception as-is
    				exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, exception, handlerMethod);
    			}
    		}
    		catch (Throwable invocationEx) {
    			//如果说异常处理再发生异常,继续往下走,让其他处理器处理之前的异常
    			//Continue with default processing of the original exception...
    			return null;
    		}
    
    		if (mavContainer.isRequestHandled()) {
    			return new ModelAndView();
    		}
    		else {
    			ModelMap model = mavContainer.getModel();
    			HttpStatus status = mavContainer.getStatus();
    			ModelAndView mav = new ModelAndView(mavContainer.getViewName(), model, status);
    			mav.setViewName(mavContainer.getViewName());
    			//...
    			return mav;
    		}
    	}
    

    doResolveHandlerMethodException(request, response, handler, ex) 方法就是要找到一个 @ExceptionHandler 注解的方法,如果没有或者这个方法也出现异常了,就让后面的处理器去处理异常。如果能找到异常处理方法,就调用该方法去进行异常处理。那么是怎么找到这个被 @ExceptionHandler 注解修饰的方法的?这才是重点啊!废话少说,就在第一行getExceptionHandlerMethod(handlerMethod, exception),我们再来看这个方法干啥了,代码加注释都没几行:

    	//缓存了所有散布在各个 Controller 层中的异常处理方法
    	private final Map<Class<?>, ExceptionHandlerMethodResolver> exceptionHandlerCache =
    			new ConcurrentHashMap<>(64);
    
    	//缓存了全局异常处理类中的方法
    	private final Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache =
    			new LinkedHashMap<>();
    
    @Nullable
    	protected ServletInvocableHandlerMethod getExceptionHandlerMethod(
    			@Nullable HandlerMethod handlerMethod, Exception exception) {
    
    		Class<?> handlerType = null;
    
    		if (handlerMethod != null) {
    			// Local exception handler methods on the controller class itself.
    			// To be invoked through the proxy, even in case of an interface-based proxy.
    			handlerType = handlerMethod.getBeanType();
    			ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(handlerType);
    			if (resolver == null) {
    				resolver = new ExceptionHandlerMethodResolver(handlerType);
    				this.exceptionHandlerCache.put(handlerType, resolver);
    			}
    			Method method = resolver.resolveMethod(exception);
    			if (method != null) {
    				return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method);
    			}
    			// For advice applicability check below (involving base packages, assignable types
    			// and annotation presence), use target class instead of interface-based proxy.
    			if (Proxy.isProxyClass(handlerType)) {
    				handlerType = AopUtils.getTargetClass(handlerMethod.getBean());
    			}
    		}
    
    		for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {
    			ControllerAdviceBean advice = entry.getKey();
    			if (advice.isApplicableToBeanType(handlerType)) {
    				ExceptionHandlerMethodResolver resolver = entry.getValue();
    				Method method = resolver.resolveMethod(exception);
    				if (method != null) {
    					return new ServletInvocableHandlerMethod(advice.resolveBean(), method);
    				}
    			}
    		}
    
    		return null;
    	}
    

    ExceptionHandlerExceptionResolver 类中有两个 Map,分别缓存 local exception handler 和全局异常处理。当尝试获取异常对应的处理器时,会先从该异常产生的类中寻找,看看该类中有没有异常处理方法,没有就再试试能不能找到全局异常处理,这两个都找不到才轮到之后的其他 HandlerExceptionResolver 类。

    再回头验证一下,第一次直接访问 /query 接口,我们什么异常处理都没有添加,走到 ExceptionHandlerExceptionResolver 时就直接返回 null,后面的 DefaultHandlerExceptionResolver 才得到机会去处理。

    DefaultHandlerExceptionResolver

    这个类继承了 AbstractHandlerExceptionResolver,它的 doResolveException(request, response, handler, ex) 方法处理了好多异常状态码的 exception,随便找几个常见的,HttpRequestMethodNotSupportedExceptionMissingServletRequestParameterExceptionBindException 等,这个类做的事情就是给 HttpResponse 设置异常状态码,然后 new 一个 ModelAndView 对象返回。

    我们再来看一下,调用 /query 接口,不传 userId 参数会报 MissingServletRequestParameterException,经过我们上面分析的流程,最终交给 DefaultHandlerExceptionResolver 处理。下面这一行日志是怎么来的?

    2019-11-30 16:53:26.853  WARN 67187 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required String parameter 'userId' is not present]
    
    

    答案就在 DefaultHandlerExceptionResolver 父类 AbstractHandlerExceptionResolver 的 logException(Exception ex, HttpServletRequest request) 方法。

    // AbstractHandlerExceptionResolver 类
    protected void logException(Exception ex, HttpServletRequest request) {
    		if (this.warnLogger != null && this.warnLogger.isWarnEnabled()) {
    			this.warnLogger.warn(buildLogMessage(ex, request));
    		}
    	}
    protected String buildLogMessage(Exception ex, HttpServletRequest request) {
    		return "Resolved [" + ex + "]";
    	}
    

    HandlerExceptionResolverComposite 是怎么来的

    从上面的截图中我们可以看到,不管是 ExceptionHandlerExceptionResolver 还是 DefaultHandlerExceptionResolver,他们都包含在 HandlerExceptionResolverComposite 类 resolvers 属性中(类型是 List),下面我们就看看 Spring Boot 是如何将这些 HandlerExceptionResolver 装载到 HandlerExceptionResolverComposite 里面的,着手点就在 HandlerExceptionResolverComposite 的 setOrder(o) 方法。

    我们找到了这个类 WebMvcConfigurationSupport,handlerExceptionResolver() 方法负责初始化这个 Bean,addDefaultHandlerExceptionResolvers(list) 方法负责加载那些 HandlerExceptionResolver,具体代码如下:

    	/**
    	 * Returns a {@link HandlerExceptionResolverComposite} containing a list of exception
    	 * resolvers obtained either through {@link #configureHandlerExceptionResolvers} or
    	 * through {@link #addDefaultHandlerExceptionResolvers}.
    	 * <p><strong>Note:</strong> This method cannot be made final due to CGLIB constraints.
    	 * Rather than overriding it, consider overriding {@link #configureHandlerExceptionResolvers}
    	 * which allows for providing a list of resolvers.
    	 */
    	@Bean
    	public HandlerExceptionResolver handlerExceptionResolver() {
    		List<HandlerExceptionResolver> exceptionResolvers = new ArrayList<>();
    		configureHandlerExceptionResolvers(exceptionResolvers);
    		if (exceptionResolvers.isEmpty()) {
    			addDefaultHandlerExceptionResolvers(exceptionResolvers);
    		}
    		extendHandlerExceptionResolvers(exceptionResolvers);
    		HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
    		composite.setOrder(0);
    		composite.setExceptionResolvers(exceptionResolvers);
    		return composite;
    	}
    
    	/**
    	 * A method available to subclasses for adding default
    	 * {@link HandlerExceptionResolver HandlerExceptionResolvers}.
    	 * <p>Adds the following exception resolvers:
    	 * <ul>
    	 * <li>{@link ExceptionHandlerExceptionResolver} for handling exceptions through
    	 * {@link org.springframework.web.bind.annotation.ExceptionHandler} methods.
    	 * <li>{@link ResponseStatusExceptionResolver} for exceptions annotated with
    	 * {@link org.springframework.web.bind.annotation.ResponseStatus}.
    	 * <li>{@link DefaultHandlerExceptionResolver} for resolving known Spring exception types
    	 * </ul>
    	 */
    	protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers) {
    		ExceptionHandlerExceptionResolver exceptionHandlerResolver = createExceptionHandlerExceptionResolver();
    		exceptionHandlerResolver.setContentNegotiationManager(mvcContentNegotiationManager());
    		exceptionHandlerResolver.setMessageConverters(getMessageConverters());
    		exceptionHandlerResolver.setCustomArgumentResolvers(getArgumentResolvers());
    		exceptionHandlerResolver.setCustomReturnValueHandlers(getReturnValueHandlers());
    		if (jackson2Present) {
    			exceptionHandlerResolver.setResponseBodyAdvice(
    					Collections.singletonList(new JsonViewResponseBodyAdvice()));
    		}
    		if (this.applicationContext != null) {
    			exceptionHandlerResolver.setApplicationContext(this.applicationContext);
    		}
    		exceptionHandlerResolver.afterPropertiesSet();
    		exceptionResolvers.add(exceptionHandlerResolver);
    
    		ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
    		responseStatusResolver.setMessageSource(this.applicationContext);
    		exceptionResolvers.add(responseStatusResolver);
    
    		exceptionResolvers.add(new DefaultHandlerExceptionResolver());
    	}
    

    存疑点

    对于那些 404 异常,为什么没有交给 DefaultHandlerExceptionResolver 处理?在 DispatcherServlet 中发现,即使 404 也能找到对应的 Handler,不抛 NoHandlerFoundException 异常就不会触发异常处理,自然就轮不到 DefaultHandlerExceptionResolver 上场了。

    为啥明明 404 了,DispatcherServlet 还能找到 Handler?

    References

    1. 深入理解 Spring 异常处理
    2. Throwable getCause()
  • 相关阅读:
    家庭记账本(七+每周总结)
    家庭记账本(六)
    家庭记账本(五)
    家庭记账本(四)
    家庭记账本(三)
    家庭记账本(二)
    家庭记账本(一)
    2021.2.14(每周总结)
    2021.2.13
    文件上传时报错in a frame because it set 'X-Frame-Options' to 'deny'.
  • 原文地址:https://www.cnblogs.com/Zhoust/p/14994580.html
Copyright © 2011-2022 走看看