亲宝软件园·资讯

展开

Springboot项目全局异常统一处理案例代码

hao_kkkkk 人气:0

最近在做项目时需要对异常进行全局统一处理,主要是一些分类入库以及记录日志等,因为项目是基于Springboot的,所以去网络上找了一些博客文档,然后再结合项目本身的一些特殊需求做了些许改造,现在记录下来便于以后查看。

在网络上找到关于Springboot全局异常统一处理的文档博客主要是两种方案:

1、基于@ControllerAdvice注解的Controller层的全局异常统一处理

以下是网上一位博主给出的代码示例,该博客地址为:https:

import org.springframework.ui.Model;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
 
import java.util.HashMap;
import java.util.Map;
 
/**
 * controller 增强器
 *
 * @author sam
 * @since 2017/7/17
 */
@ControllerAdvice
public class MyControllerAdvice {
 
    /**
     * 全局异常捕捉处理
     * @param ex
     * @return
     */
    @ResponseBody
    @ExceptionHandler(value = Exception.class)
    public Map errorHandler(Exception ex) {
        Map map = new HashMap();
        map.put("code", 100);
        map.put("msg", ex.getMessage());
        return map;
    }
    
    /**
     * 拦截捕捉自定义异常 MyException.class
     * @param ex
     * @return
     */
    @ResponseBody
    @ExceptionHandler(value = MyException.class)
    public Map myErrorHandler(MyException ex) {
        Map map = new HashMap();
        map.put("code", ex.getCode());
        map.put("msg", ex.getMsg());
        return map;
    }
 
}

这个代码示例写的非常浅显易懂,但是需要注意的是:基于@ControllerAdvice注解的全局异常统一处理只能针对于Controller层的异常,意思是只能捕获到Controller层的异常,在service层或者其他层面的异常都不能捕获。

根据这段示例代码以及结合项目本身的实际需求,对该实例代码做了稍微改造(其实几乎没做改造,只是业务处理不一样而已):

@ControllerAdvice
public class AdminExceptionHandler {
 
    private static final Logger logger = LoggerFactory.getLogger(AdminExceptionHandler.class);
 
    /**
      * @Author: gmy
      * @Description: 系统异常捕获处理
      * @Date: 16:07 2018/5/30
      */
    @ResponseBody
    @ExceptionHandler(value = Exception.class)
    public APIResponse javaExceptionHandler(Exception ex) {//APIResponse是项目中对外统一的出口封装,可以根据自身项目的需求做相应更改
        logger.error("捕获到Exception异常",ex);
        //异常日志入库
 
        return new APIResponse(APIResponse.FAIL,null,ex.getMessage());
    }
 
    /**
      * @Author: gmy
      * @Description: 自定义异常捕获处理
      * @Date: 16:08 2018/5/30
      */
    @ResponseBody
    @ExceptionHandler(value = MessageCenterException.class)//MessageCenterException是自定义的一个异常
    public APIResponse messageCenterExceptionHandler(MessageCenterException ex) {
        logger.error("捕获到MessageCenterException异常",ex.getException());
        //异常日志入库
 
        return ex.getApiResponse();
    }
 
}
public class MessageCenterException extends RuntimeException {
 
    public MessageCenterException(APIResponse apiResponse, Exception exception){
        this.apiResponse = apiResponse;
        this.exception = exception;
    }
 
    private Exception exception;
    private APIResponse apiResponse;
 
    public Exception getException() {
        return exception;
    }
 
    public void setException(Exception exception) {
        this.exception = exception;
    }
 
    public APIResponse getApiResponse() {
        return apiResponse;
    }
 
    public void setApiResponse(APIResponse apiResponse) {
        this.apiResponse = apiResponse;
    }
}

经过测试发现可以捕获到Controller层的异常,当前前提是Controller层没有对异常进行catch处理,如果Controller层对异常进行了catch处理,那么在这里就不会捕获到Controller层的异常了,所以这一点要特别注意。

在实际测试中还发现,如果在Controller中不做异常catch处理,在service中抛出异常(service中也不错异常catch处理),那么也是可以在这里捕获到异常的。

2、基于Springboot自身的全局异常统一处理,主要是实现ErrorController接口或者继承AbstractErrorController抽象类或者继承BasicErrorController类

以下是网上一位博主给出的示例代码,博客地址为:https:

@Controller
@RequestMapping(value = "error")
@EnableConfigurationProperties({ServerProperties.class})
public class ExceptionController implements ErrorController {
 
    private ErrorAttributes errorAttributes;
 
    @Autowired
    private ServerProperties serverProperties;
 
 
    /**
     * 初始化ExceptionController
     * @param errorAttributes
     */
    @Autowired
    public ExceptionController(ErrorAttributes errorAttributes) {
        Assert.notNull(errorAttributes, "ErrorAttributes must not be null");
        this.errorAttributes = errorAttributes;
    }
 
 
    /**
     * 定义404的ModelAndView
     * @param request
     * @param response
     * @return
     */
    @RequestMapping(produces = "text/html",value = "404")
    public ModelAndView errorHtml404(HttpServletRequest request,
                                  HttpServletResponse response) {
        response.setStatus(getStatus(request).value());
        Map<String, Object> model = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.TEXT_HTML));
        return new ModelAndView("error/404", model);
    }
 
    /**
     * 定义404的JSON数据
     * @param request
     * @return
     */
    @RequestMapping(value = "404")
    @ResponseBody
    public ResponseEntity<Map<String, Object>> error404(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.TEXT_HTML));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<Map<String, Object>>(body, status);
    }
 
    /**
     * 定义500的ModelAndView
     * @param request
     * @param response
     * @return
     */
    @RequestMapping(produces = "text/html",value = "500")
    public ModelAndView errorHtml500(HttpServletRequest request,
                                  HttpServletResponse response) {
        response.setStatus(getStatus(request).value());
        Map<String, Object> model = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.TEXT_HTML));
        return new ModelAndView("error/500", model);
    }
 
 
    /**
     * 定义500的错误JSON信息
     * @param request
     * @return
     */
    @RequestMapping(value = "500")
    @ResponseBody
    public ResponseEntity<Map<String, Object>> error500(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.TEXT_HTML));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<Map<String, Object>>(body, status);
    }
 
 
    /**
     * Determine if the stacktrace attribute should be included.
     * @param request the source request
     * @param produces the media type produced (or {@code MediaType.ALL})
     * @return if the stacktrace attribute should be included
     */
    protected boolean isIncludeStackTrace(HttpServletRequest request,
                                          MediaType produces) {
        ErrorProperties.IncludeStacktrace include = this.serverProperties.getError().getIncludeStacktrace();
        if (include == ErrorProperties.IncludeStacktrace.ALWAYS) {
            return true;
        }
        if (include == ErrorProperties.IncludeStacktrace.ON_TRACE_PARAM) {
            return getTraceParameter(request);
        }
        return false;
    }
 
 
    /**
     * 获取错误的信息
     * @param request
     * @param includeStackTrace
     * @return
     */
    private Map<String, Object> getErrorAttributes(HttpServletRequest request,
                                                   boolean includeStackTrace) {
        RequestAttributes requestAttributes = new ServletRequestAttributes(request);
        return this.errorAttributes.getErrorAttributes(requestAttributes,
                includeStackTrace);
    }
 
    /**
     * 是否包含trace
     * @param request
     * @return
     */
    private boolean getTraceParameter(HttpServletRequest request) {
        String parameter = request.getParameter("trace");
        if (parameter == null) {
            return false;
        }
        return !"false".equals(parameter.toLowerCase());
    }
 
    /**
     * 获取错误编码
     * @param request
     * @return
     */
    private HttpStatus getStatus(HttpServletRequest request) {
        Integer statusCode = (Integer) request
                .getAttribute("javax.servlet.error.status_code");
        if (statusCode == null) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
        try {
            return HttpStatus.valueOf(statusCode);
        }
        catch (Exception ex) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
    }
 
    /**
     * 实现错误路径,暂时无用
     * @see ExceptionMvcAutoConfiguration#containerCustomizer()
     * @return
     */
    @Override
    public String getErrorPath() {
        return "";
    }
 
}

该示例写的也是非常简单明了的,但是结合本身项目的实际需求,也是不能直接拿来用的,需要做相应的改造,改造主要有以下方面:

1、因为项目是前后端分离的,所以Controller层不会有ModelAndView返回类型,需要返回自身的APIResponse返回类型

2、项目需要统计全部的异常,而不只是404或者500的异常

3、捕获到异常之后需要做特殊化的业务处理

所以基于以上几方面对示例代码做了改造,具体改造代码如下:

/**
 * @Author: gmy
 * @Description: Springboot全局异常统一处理
 * @Date: 2018/5/30
 * @Time: 16:41
 */
@RestController
@EnableConfigurationProperties({ServerProperties.class})
public class ExceptionController implements ErrorController {
 
    private ErrorAttributes errorAttributes;
 
    @Autowired
    private ServerProperties serverProperties;
 
 
    /**
     * 初始化ExceptionController
     * @param errorAttributes
     */
    @Autowired
    public ExceptionController(ErrorAttributes errorAttributes) {
        Assert.notNull(errorAttributes, "ErrorAttributes must not be null");
        this.errorAttributes = errorAttributes;
    }
 
 
    @RequestMapping(value = "/error") 
    @ResponseBody
    public APIResponse error(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.ALL));
        HttpStatus status = getStatus(request);
        return new APIResponse(APIResponse.FAIL,null,body.get("message").toString());
    }
 
 
 
 
    /**
     * Determine if the stacktrace attribute should be included.
     * @param request the source request
     * @param produces the media type produced (or {@code MediaType.ALL})
     * @return if the stacktrace attribute should be included
     */
    protected boolean isIncludeStackTrace(HttpServletRequest request,
                                          MediaType produces) {
        ErrorProperties.IncludeStacktrace include = this.serverProperties.getError().getIncludeStacktrace();
        if (include == ErrorProperties.IncludeStacktrace.ALWAYS) {
            return true;
        }
        if (include == ErrorProperties.IncludeStacktrace.ON_TRACE_PARAM) {
            return getTraceParameter(request);
        }
        return false;
    }
 
 
    /**
     * 获取错误的信息
     * @param request
     * @param includeStackTrace
     * @return
     */
    private Map<String, Object> getErrorAttributes(HttpServletRequest request,
                                                   boolean includeStackTrace) {
        RequestAttributes requestAttributes = new ServletRequestAttributes(request);
        return this.errorAttributes.getErrorAttributes(requestAttributes,
                includeStackTrace);
    }
 
    /**
     * 是否包含trace
     * @param request
     * @return
     */
    private boolean getTraceParameter(HttpServletRequest request) {
        String parameter = request.getParameter("trace");
        if (parameter == null) {
            return false;
        }
        return !"false".equals(parameter.toLowerCase());
    }
 
    /**
     * 获取错误编码
     * @param request
     * @return
     */
    private HttpStatus getStatus(HttpServletRequest request) {
        Integer statusCode = (Integer) request
                .getAttribute("javax.servlet.error.status_code");
        if (statusCode == null) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
        try {
            return HttpStatus.valueOf(statusCode);
        }
        catch (Exception ex) {
            return HttpStatus.INTERNAL_SERVER_ERROR;
        }
    }
 
    /**
     * 实现错误路径,暂时无用
     * @return
     */
    @Override
    public String getErrorPath() {
        return "";
    }
 
}

经过测试,可以捕获到所有层面上的异常,当前前提仍然是没有对异常进行catch处理,否则这里也是捕获不到

以上为网络上常用的两种全局异常统一处理方案,经过实际测试发现都可以实现满足要求。

其实基于AOP也可以实现异常的全局处理,自己相应的做了测试发现也满足要求,相应的代码如下:

/**
 * @Author: gmy
 * @Description: 基于AOP的全局异常统一处理
 * @Date: 2018/6/1
 * @Time: 13:46
 */
@Component
@Aspect
public class ExceptionAspectController {
    public static final Logger logger = LoggerFactory.getLogger(ExceptionAspectController.class);
 
    @Pointcut("execution(* com.test.test.*.*(..))")//此处基于自身项目的路径做具体的设置
    public void pointCut(){}
 
    @Around("pointCut()")
    public Object handleControllerMethod(ProceedingJoinPoint pjp) {
        Stopwatch stopwatch = Stopwatch.createStarted();
 
        APIResponse<?> apiResponse;
        try {
            logger.info("执行Controller开始: " + pjp.getSignature() + " 参数:" + Lists.newArrayList(pjp.getArgs()).toString());
            apiResponse = (APIResponse<?>) pjp.proceed(pjp.getArgs());
            logger.info("执行Controller结束: " + pjp.getSignature() + ", 返回值:" + apiResponse.toString());
            logger.info("耗时:" + stopwatch.stop().elapsed(TimeUnit.MILLISECONDS) + "(毫秒).");
        } catch (Throwable throwable) {
            apiResponse = handlerException(pjp, throwable);
        }
 
        return apiResponse;
    }
 
    private APIResponse<?> handlerException(ProceedingJoinPoint pjp, Throwable e) {
        APIResponse<?> apiResponse = null;
        if(e.getClass().isAssignableFrom(MessageCenterException.class) ){
            MessageCenterException messageCenterException = (MessageCenterException)e;
            logger.error("RuntimeException{方法:" + pjp.getSignature() + ", 参数:" + pjp.getArgs() + ",异常:" + messageCenterException.getException().getMessage() + "}", e);
            apiResponse = messageCenterException.getApiResponse();
        } else if (e instanceof RuntimeException) {
            logger.error("RuntimeException{方法:" + pjp.getSignature() + ", 参数:" + pjp.getArgs() + ",异常:" + e.getMessage() + "}", e);
            apiResponse = new APIResponse(APIResponse.FAIL,null,e.getMessage());
        } else {
            logger.error("异常{方法:" + pjp.getSignature() + ", 参数:" + pjp.getArgs() + ",异常:" + e.getMessage() + "}", e);
            apiResponse = new APIResponse(APIResponse.FAIL,null,e.getMessage());
        }
 
        return apiResponse;
    }
}

经过测试,在执行切点中配置的路径中的方法有异常时,可以被这里捕获到。

以上是自己了解到并且亲自测试可行的全局异常统一处理方案,如果各位博友有什么问题或者有什么新的方案可以一块探讨下

2018/11/28最新编辑

经过一段时间的使用,现在项目里已经统一使用AOP方式来做全局异常统一处理了,选用AOP方式主要是因为AOP不只可以做全局异常统一处理还可以统一打印接口请求入参和返回结果日志,打印接口访问性能日志,处理sql注入攻击以及处理入参特殊字符等问题

下面贴出代码,供大家参考,也仅供参考

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.google.common.base.Stopwatch;
import com.google.common.collect.Lists;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
 
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
 
/**
 * @Author: gmy
 * @Description: 调用接口打印性能日志以及接口报错之后记录错误日志
 * @Date: 2018/9/20
 * @Time: 15:16
 */
@Component
@Aspect
public class InterfaceRequestErrrorAndPerformanceLog {
 
    public static final Logger logger = LoggerFactory.getLogger(InterfaceRequestErrrorAndPerformanceLog.class);
 
    @Value("${dc.log.bad.value:3000}")
    private int performanceBadValue;
 
    @Resource
    private RabbitMQService rabbitMQService;
    @Resource
    private InterfaceErrorService interfaceErrorService;
 
    @Pointcut("execution(* test.test.test.test.test.controller.*.*.*(..))")
    public void pointCut(){}
 
    @Around("pointCut()")
    public APIResponse handleControllerMethod(ProceedingJoinPoint pjp) throws Throwable{
        Stopwatch stopwatch = Stopwatch.createStarted();
 
        APIResponse apiResponse;
        try {
            logger.info("执行Controller开始: " + pjp.getSignature() + " 参数:" + Lists.newArrayList(pjp.getArgs()).toString());
            //处理入参特殊字符和sql注入攻击
            checkRequestParam(pjp);
            //执行访问接口操作
            apiResponse = (APIResponse) pjp.proceed(pjp.getArgs());
            try{
                logger.info("执行Controller结束: " + pjp.getSignature() + ", 返回值:" + JSONObject.toJSONString(apiResponse));
                //此处将日志打印放入try-catch是因为项目中有些对象实体bean过于复杂,导致序列化为json的时候报错,但是此处报错并不影响主要功能使用,只是返回结果日志没有打印,所以catch中也不做抛出异常处理
            }catch (Exception ex){
                logger.error(pjp.getSignature()+" 接口记录返回结果失败!,原因为:{}",ex.getMessage());
            }
            Long consumeTime = stopwatch.stop().elapsed(TimeUnit.MILLISECONDS);
            logger.info("耗时:" + consumeTime + "(毫秒).");
            //当接口请求时间大于3秒时,标记为异常调用时间,并记录入库
            if(consumeTime > performanceBadValue){
                DcPerformanceEntity dcPerformanceEntity = new DcPerformanceEntity();
                dcPerformanceEntity.setInterfaceName(pjp.getSignature().toString());
                dcPerformanceEntity.setRequestParam(Lists.newArrayList(pjp.getArgs()).toString());
                dcPerformanceEntity.setConsumeTime(consumeTime + "毫秒");
                RabbitMQMessageTarget mqTarget = RabbitMQMessageTarget.createFanoutTarget(ProjectConstants.DC_KEY_EXCHANGE_PERFORMANCE, new String[] { ProjectConstants.DC_KEY_QUEUE_PERFORMANCE});
                rabbitMQService.send(mqTarget, JSON.toJSONString(dcPerformanceEntity));
            }
        } catch (Exception throwable) {
            apiResponse = handlerException(pjp, throwable);
        }
 
        return apiResponse;
    }
 
    /**
      * @Author: gmy
      * @Description: 处理接口调用异常
      * @Date: 15:13 2018/10/25
      */
    private APIResponse handlerException(ProceedingJoinPoint pjp, Throwable e) {
        APIResponse apiResponse;
        if(e.getClass().isAssignableFrom(ProjectException.class) ){
            //ProjectException为自定义异常类,项目中Controller层会把所有的异常都catch掉,并手工封装成ProjectException抛出来,这样做的目的是ProjectException会记录抛出异常接口的路径,名称以及请求参数等等,有助于错误排查
            ProjectException projectException = (ProjectException)e;
            logger.error("捕获到ProjectException异常:",JSONObject.toJSONString(projectException.getDcErrorEntity()));
            RabbitMQMessageTarget mqTarget = RabbitMQMessageTarget.createFanoutTarget(ProjectConstants.DC_KEY_EXCHANGE_INTERFACE_ERROR, new String[] { ProjectConstants.DC_KEY_QUEUE_INTERFACE_ERROR});
            rabbitMQService.send(mqTarget, JSON.toJSONString(dataCenterException.getDcErrorEntity()));
            apiResponse = new APIResponse(APIResponse.FAIL,null,projectException.getDcErrorEntity().getErrorMessage());
        } else if (e instanceof RuntimeException) {
            logger.error("RuntimeException{方法:" + pjp.getSignature() + ", 参数:" + pjp.getArgs() + ",异常:" + e.getMessage() + "}", e);
            apiResponse = new APIResponse(APIResponse.FAIL,null,e.getMessage());
        } else {
            logger.error("异常{方法:" + pjp.getSignature() + ", 参数:" + pjp.getArgs() + ",异常:" + e.getMessage() + "}", e);
            apiResponse = new APIResponse(APIResponse.FAIL,null,e.getMessage());
        }
 
        return apiResponse;
    }
 
    /**
      * @Author: gmy
      * @Description: 处理入参特殊字符和sql注入攻击
      * @Date: 15:37 2018/10/25
      */
    private void checkRequestParam(ProceedingJoinPoint pjp){
        String str = String.valueOf(pjp.getArgs());
        if (!IllegalStrFilterUtil.sqlStrFilter(str)) {
            logger.info("访问接口:" + pjp.getSignature() + ",输入参数存在SQL注入风险!参数为:" + Lists.newArrayList(pjp.getArgs()).toString());
            DcErrorEntity dcErrorEntity = interfaceErrorService.processDcErrorEntity(pjp.getSignature() + "",Lists.newArrayList(pjp.getArgs()).toString(),"输入参数存在SQL注入风险!");
            throw new DataCenterException(dcErrorEntity);
        }
        if (!IllegalStrFilterUtil.isIllegalStr(str)) {
            logger.info("访问接口:" + pjp.getSignature() + ",输入参数含有非法字符!,参数为:" + Lists.newArrayList(pjp.getArgs()).toString());
            DcErrorEntity dcErrorEntity = interfaceErrorService.processDcErrorEntity(pjp.getSignature() + "",Lists.newArrayList(pjp.getArgs()).toString(),"输入参数含有非法字符!");
            throw new DataCenterException(dcErrorEntity);
        }
    }
 
}

代码中使用了一些其他的工具类,比如IllegalStrFilterUtil等,我也把代码贴出来

import org.slf4j.LoggerFactory;
 
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
/**
 * @Author: gmy
 * @Description: 特殊字符检测工具(防止传入非法字符和sql注入攻击)
 * @Date: 2018/10/25
 * @Time: 15:08
 */
public class IllegalStrFilterUtil {
    private static final org.slf4j.Logger Logger = LoggerFactory.getLogger(IllegalStrFilterUtil.class);
 
    private static final String REGX = "!|!|@|◎|#|#|(\\$)|¥|%|%|(\\^)|……|(\\&)|※|(\\*)|×|(\\()|(|(\\))|)|_|——|(\\+)|+|(\\|)|§ ";
 
    /**
     * 对常见的sql注入攻击进行拦截
     *
     * @param sInput
     * @return
     *  true 表示参数不存在SQL注入风险
     *  false 表示参数存在SQL注入风险
     */
    public static Boolean sqlStrFilter(String sInput) {
        if (sInput == null || sInput.trim().length() == 0) {
            return false;
        }
        sInput = sInput.toUpperCase();
 
        if (sInput.indexOf("DELETE") >= 0 || sInput.indexOf("ASCII") >= 0 || sInput.indexOf("UPDATE") >= 0 || sInput.indexOf("SELECT") >= 0
                || sInput.indexOf("'") >= 0 || sInput.indexOf("SUBSTR(") >= 0 || sInput.indexOf("COUNT(") >= 0 || sInput.indexOf(" OR ") >= 0
                || sInput.indexOf(" AND ") >= 0 || sInput.indexOf("DROP") >= 0 || sInput.indexOf("EXECUTE") >= 0 || sInput.indexOf("EXEC") >= 0
                || sInput.indexOf("TRUNCATE") >= 0 || sInput.indexOf("INTO") >= 0 || sInput.indexOf("DECLARE") >= 0 || sInput.indexOf("MASTER") >= 0) {
            Logger.error("该参数怎么SQL注入风险:sInput=" + sInput);
            return false;
        }
        Logger.info("通过sql检测");
        return true;
    }
 
    /**
     * 对非法字符进行检测
     *
     * @param sInput
     * @return
     *  true 表示参数不包含非法字符
     *  false 表示参数包含非法字符
     */
    public static Boolean isIllegalStr(String sInput) {
 
        if (sInput == null || sInput.trim().length() == 0) {
            return false;
        }
        sInput = sInput.trim();
        Pattern compile = Pattern.compile(REGX, Pattern.CASE_INSENSITIVE);
        Matcher matcher = compile.matcher(sInput);
        Logger.info("通过字符串检测");
        return matcher.find();
    }
}

以上代码中涉及到真实项目信息的内容我都做了相应修改,代码仅供技术交流使用。

加载全部内容

相关教程
猜你喜欢
用户评论