java日志框架总结(七、使用过滤器自动打印接口入参、出参)_java打印接口里所有的内容

最后:学习总结——MyBtis知识脑图(纯手绘xmind文档)

学完之后,若是想验收效果如何,其实最好的方法就是可自己去总结一下。比如我就会在学习完一个东西之后自己去手绘一份xmind文件的知识梳理大纲脑图,这样也可方便后续的复习,且都是自己的理解,相信随便瞟几眼就能迅速过完整个知识,脑补回来。下方即为我手绘的MyBtis知识脑图,由于是xmind文件,不好上传,所以小编将其以图片形式导出来传在此处,细节方面不是特别清晰。但可给感兴趣的朋友提供完整的MyBtis知识脑图原件(包括上方的面试解析xmind文档)

image

除此之外,前文所提及的Alibaba珍藏版mybatis手写文档以及一本小小的MyBatis源码分析文档——《MyBatis源码分析》等等相关的学习笔记文档,也皆可分享给认可的朋友!

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

public void setNeedLogRequest(boolean needLogRequest) {
    this.needLogRequest = needLogRequest;
}

public boolean isNeedLogResponse() {
    return needLogResponse;
}

public void setNeedLogResponse(boolean needLogResponse) {
    this.needLogResponse = needLogResponse;
}

public boolean isNeedLogHeader() {
    return needLogHeader;
}

public void setNeedLogHeader(boolean needLogHeader) {
    this.needLogHeader = needLogHeader;
}

public boolean isNeedLogPayload() {
    return needLogPayload;
}

public void setNeedLogPayload(boolean needLogPayload) {
    this.needLogPayload = needLogPayload;
}

public int getMaxPayloadLength() {
    return maxPayloadLength;
}

public void setMaxPayloadLength(int maxPayloadLength) {
    this.maxPayloadLength = maxPayloadLength;
}

public List<String> getExcludeUrlPatterns() {
    return excludeUrlPatterns;
}

public void setExcludeUrlPatterns(List<String> excludeUrlPatterns) {
    this.excludeUrlPatterns = excludeUrlPatterns;
}

}


上述代码解释:  
         1、首先请求进入过滤器之后,先进入shouldNotFilter方法,通过 getServletPath() 获取访问路径,然后再根据**`AntPathMatche`类(专门用来进行路劲匹配的,可以单独了解一下)**来进行路径匹配,看是否是需要进行过滤,如果是就返回false 代表执行这个过滤器。


        2、然后请求进入doFilterInternal方法,先进行一系列判断,然后如果需要记录日志,就将HttpServletRequest 对象转换为 ContentCachingRequestWrapper 对象,转换成ContentCachingRequestWrapper对象是为了缓存请求体内容,并允许多次读取和修改。因为`HttpServletRequest` 对象只能被读取一次,读取后的数据就无法再次获取。所以一般都会先转换为ContentCachingRequestWrapper对象类型。


        然后经过判断,再将HttpServletResponse 转换为:ContentCachingResponseWrapper,原因同理。


        3、最后在finally 中打印 请求体数据和响应体数据,  
         首先 logRequest 方法记录请求日志,在logRequest方法中经过判断,进 getRequestPayload 方法,目的是为了获取请求体中参数,在这个方法中用到了一个方法:



> 
>         WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
> 
> 
> 


        这个方法我的理解是为了将HttpServletRequest对象 转换成 ContentCachingRequestWrapper对象 ,目的在上面也讲了,拿到ContentCachingRequestWrapper对象之后,就可以根据 wrapper.getContentAsByteArray() 方法获取请求体的字节数据了。再根据 getPayloadFromBuf 方法将字节数据转换成字符串。


        获取到请求数据之后,再根据 createRequestMessage 方法拼接需要打印的数据即可。


        响应数据同理,但需要注意的是,我们需要使用 wrapper.copyBodyToResponse() 方法,重新将响应参数设置到response中,不然客户端获取不到响应数据!



#####         示例二:



import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONValidator;
import jdk.nashorn.internal.ir.annotations.Ignore;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

@Slf4j
@Component
//@Order()
public class LogFilter extends OncePerRequestFilter implements Ordered {

/**
 *  配置要记录请求的路径前缀
 */
private static final String NEED_TRACE_PATH_PREFIX = "/";
/**
 * 忽略为multipart/form-data的ContentType的请求
 */
private static final Set<String> IGNORE_CONTENT_TYPE =new HashSet<>(Arrays.asList("multipart/form-data","application/octet-stream"));

@Override
public int getOrder() {
    return Ordered.HIGHEST_PRECEDENCE+1 ;
}


@Override
@SuppressWarnings("NullableProblems")
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    if (!isRequestValid(request)) {
        filterChain.doFilter(request, response);
        return;
    }
    RequestWrapper request1=new RequestWrapper(request);
    ResponseWrapper response1=new ResponseWrapper(response);
    int status = HttpStatus.INTERNAL_SERVER_ERROR.value();
    long startTime = System.currentTimeMillis();
    String path = request.getRequestURI();
    try {
        if (path.startsWith(NEED_TRACE_PATH_PREFIX)) {
            // 1. 记录日志
            consoleRequestLog(request1);
        }
    } catch (Exception ignore){
        log.error("请求日志打印异常",ignore);
    }
    filterChain.doFilter(request1, response1);
    status = response1.getStatus();
    try {
        if (path.startsWith(NEED_TRACE_PATH_PREFIX)) {
            // 1. 记录日志
            consoleResponseLog(path, startTime, status, response1);
        }
        updateResponse(response1, response);
    } catch (Exception ignore) {
        log.error("响应日志输出异常",ignore);
    }

}

private Boolean ignoreCheck(String contentType){
    if(StrUtil.isNotBlank(contentType)) {
        for (String s : IGNORE_CONTENT_TYPE) {
            if (contentType.contains(s)){
                return true;
            }
        }
    }
    return false;
}

/**
 * 输出请求日志
 * @param request
 */
private synchronized void consoleRequestLog(RequestWrapper request){
    log.info("请求 | 请求路径:[{}] | 请求方法:[{}] | 请求IP:[{}] | 请求参数:{} | 请求Body:{} ",
            request.getRequestURI(),
            request.getMethod(),
            request.getRemoteAddr(),
            JSON.toJSONString(request.getParameterMap()),
            getRequestBody(request)
    );
}

/**
 * 获取请求body
 * @param request
 * @return 请求body
 */
private String getRequestBody(RequestWrapper request) {
    String requestBody="{}";

// ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
// if (wrapper != null) {
try {
// requestBody =new String(wrapper.getContentAsByteArray(), wrapper.getCharacterEncoding());
requestBody =request.getBody();
if (StrUtil.isNotBlank(requestBody) && (JSONValidator.from(requestBody).validate() || (requestBody.startsWith(“<”) && requestBody.endsWith(“>”)))) {
return requestBody;
} else if (StrUtil.isNotBlank(requestBody)) {
return “IOStream”;
} else {
return “”;
}
} catch (Exception ignore) {
log.error(“请求体转换异常”,ignore);
}
// }
return requestBody;
}

/**
 * @Description: 打印日志
 * @Param: [path - 请求路径, request - Http请求, startTime - 开始毫秒, status - 响应状态码, response - Http响应]
 */
private synchronized void consoleResponseLog(String path, long startTime, int status, ResponseWrapper response) {
    log.info("返回 |  处理耗时:[{}ms] | 响应时间:[{}] | 响应状态:[{}] | 响应Body:{} ",
            System.currentTimeMillis() - startTime,
            LocalDateTime.now(),
            status,
            getResponseBody(response)
    );
}

/**
 * @Description: 判断请求是否合法
 * @Param: [request]
 * @return: {@link boolean}
 */
private boolean isRequestValid(HttpServletRequest request) {
    try {
        new URI(request.getRequestURL().toString());
        return true;
    } catch (URISyntaxException ex) {
        return false;
    }
}



/**
 * @Description: 获取响应Body
 * @Param: [response]
 * @return: {@link String}
 */
private String getResponseBody(ResponseWrapper response) {
    if (Objects.isNull(response.getDataStream())){
        return "";
    }
    String responseBody = new String(response.getDataStream());;
    if(JSONValidator.from(responseBody).validate()||responseBody.startsWith("<")&&responseBody.endsWith(">")) {
        return responseBody;
    }else{
        return "FileStream";
    }
}

/**
 * @Description: 更新响应
 * @Param: [response]
 */
private void updateResponse(ResponseWrapper response1,HttpServletResponse response) throws IOException {
    response.getOutputStream().write(response1.getDataStream());
    response.getOutputStream().flush();

// ContentCachingResponseWrapper responseWrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
// Objects.requireNonNull(responseWrapper).copyBodyToResponse();
}

}



import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.ByteUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.web.util.ContentCachingRequestWrapper;

import javax.servlet.ReadListener;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.Part;
import java.io.*;
import java.util.Arrays;
import java.util.Collection;
import java.util.Objects;

@Slf4j
public class RequestWrapper extends ContentCachingRequestWrapper {

private final String MULTIPARTHEADER="multipart/form-data";
private byte[] body;
private Collection<Part> parts;

private BufferedReader reader;

private ServletInputStream inputStream;

public RequestWrapper(HttpServletRequest request) throws IOException, ServletException {
    super(request);
    if (StrUtil.isNotBlank(request.getContentType())&&request.getContentType().contains(MULTIPARTHEADER)){
        parts=request.getParts();
    }else {
        //读一次 然后缓存起来
        body = IoUtil.readBytes(request.getInputStream());
        inputStream = new RequestCachingInputStream(body);
    }
}

@Override
public Collection<Part> getParts() throws IOException, ServletException {
    return parts;
}

public String getBody() {
    try {
        if (Objects.nonNull(body)&&body.length>0) {
            return new String(body, getCharacterEncoding());
        }else{
            return "";
        }
    } catch (UnsupportedEncodingException e) {
        throw new RuntimeException(e);
    }
}

@Override
public ServletInputStream getInputStream() throws IOException {
    if (inputStream != null) {
        return inputStream;
    }
    return new RequestCachingInputStream(body);
}

@Override
public BufferedReader getReader() throws IOException {
    if (reader == null) {
        reader = new BufferedReader(new InputStreamReader(inputStream, getCharacterEncoding()));
    }
    return reader;
}

private static class RequestCachingInputStream extends ServletInputStream {

    private final ByteArrayInputStream inputStream;

    public RequestCachingInputStream(byte[] bytes) {
        inputStream = new ByteArrayInputStream(bytes);
    }
    @Override
    public int read() throws IOException {
        return inputStream.read();
    }

    @Override
    public boolean isFinished() {
        return inputStream.available() == 0;
    }

    @Override
    public boolean isReady() {
        return true;
    }

    @Override
    public void setReadListener(ReadListener readlistener) {
    }

}

}



import org.springframework.web.util.ContentCachingResponseWrapper;

import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

public class ResponseWrapper extends ContentCachingResponseWrapper {

public ResponseWrapper(HttpServletResponse response) {
    super(response);
}


public byte[] getDataStream() {
    return getContentAsByteArray();
}

}




        代码解释:  
         1、首先实现了 Ordered  接口,需要实现 `getOrder()` 方法来指定 Bean 的加载顺序。


        2、请求进入之后的逻辑类似,先进入  doFilterInternal() 方法,先判断请求是否合法,如果不合法就不进行日志打印。


        3、然后将 HttpServletRequest 转换为 RequestWrapper 对象,其实RequestWrapper对象是自定义对象,继承了  ContentCachingRequestWrapper 对象。所以实际上还是转换成了 ContentCachingRequestWrapper 对象,主要是为了缓存请求数据。 



> 
> `RequestWrapper` 的类,这个类继承自 `ContentCachingRequestWrapper`,并且用于包装 `HttpServletRequest` 对象。
> 
> 
> **主要功能:**
> 
> 
> 1. **处理请求体内容**:
> 
> 
> 	* 当请求的 `Content-Type` 是 `multipart/form-data` 时,通过 `request.getParts()` 获取多部分请求的所有 `Part` 对象。
> 	* 否则,将请求体内容读取一次并缓存起来,以便后续的读取。
> 2. **提供方法获取请求体内容**:
> 
> 
> 	* `getBody()`:返回请求体内容的字符串表示。
> 	* `getInputStream()`:返回输入流,可以用于读取请求体内容。
> 	* `getReader()`:返回字符流的 `BufferedReader` 对象,可以用于读取请求体内容。
> 
> 
> **代码解析:**
> 
> 
> 1. 构造方法 `RequestWrapper(HttpServletRequest request)`:
> 
> 
> 	* 根据请求的 `Content-Type` 来判断是否是 `multipart/form-data` 类型的请求。
> 	* 如果是 `multipart/form-data`,则调用 `request.getParts()` 获取所有的 `Part` 对象。
> 	* 否则,读取一次请求体内容,并将其缓存在 `body` 数组中。
> 2. `getParts()` 方法:
> 
> 
> 	* 如果是 `multipart/form-data` 请求,则直接返回之前获取的 `parts` 集合。
> 	* 否则,返回 `null`。
> 3. `getBody()` 方法:
> 
> 
> 	* 返回请求体内容的字符串表示,首先判断 `body` 数组是否为空,然后将其转换为字符串返回。
> 4. `getInputStream()` 方法:
> 
> 
> 	* 如果 `inputStream` 不为空,则直接返回该输入流。
> 	* 否则,创建一个新的 `RequestCachingInputStream` 对象,并将之前缓存的 `body` 数组传入其中。
> 5. `getReader()` 方法:
> 
> 
> 	* 如果 `reader` 为空,则创建一个新的 `BufferedReader` 对象,并使用 `inputStream` 创建。
> 	* 否则,直接返回之前创建的 `reader` 对象。
> 6. `RequestCachingInputStream` 内部类:
> 
> 
> 	* 继承自 `ServletInputStream`,用于提供一个包装 `body` 数组的输入流。
> 	* 实现了 `read()` 方法,用于读取字节数据。
> 	* 实现了 `isFinished()` 方法,用于判断输入流是否已经读取完毕。
> 	* 实现了 `isReady()` 方法,始终返回 `true`。
> 	* 实现了 `setReadListener()` 方法,空实现。
> 
> 
> 


        4、缓存完请求对象的内容之后, 使用consoleRequestLog()方法打印日志,在这个方法中,它记录了  请求路径:[{}] 、 请求方法:[{}]、请求IP:[{}]、请求参数:{} 、 请求Body:{},request.getParameterMap() 就是专门获取get请求  请求参数的。


        其中 请求Body 需要使用 getRequestBody 方法获取,这个方法中主要是这个判断需要说一下:



if (StrUtil.isNotBlank(requestBody) && (JSONValidator.from(requestBody).validate() || (requestBody.startsWith(“<”) && requestBody.endsWith(“>”))))


 这个判断主要是为了  requestBody 不为空时,且当请求体是 json数据时,或xml数据时才进行打印。


* `JSONValidator.from(requestBody).validate()`:使用 JSON 格式验证器验证请求体内容是否是有效的 JSON 格式。
* `(requestBody.startsWith("<") && requestBody.endsWith(">"))`:检查请求体内容是否以 `<` 开头且以 `>` 结尾。


        响应数据同理!



####         三、MDC:



> 
> `MDC`,即 `Mapped Diagnostic Context`,是 logback 日志框架提供的一种上下文信息存储的机制,可以在日志输出中方便地添加和显示额外的上下文信息。
> 
> 
> 
> MDC 的作用:
> 
> 
> MDC 允许你在一个线程中存储一些额外的信息,并在该线程执行的任何代码中访问这些信息。这些信息可以是任何与日志记录相关的数据,比如用户 ID、请求 ID、会话 ID 等。通过 MDC,你可以将这些信息附加到日志消息中,使日志更加丰富和有用。
> 
> 
> 


        简单说一下使用 MDC 添加日志链路追踪id 和  ip  以及  userAgent



import cn.hutool.core.lang.UUID;

import cn.hutool.http.Header;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@WebFilter(filterName = “logRequestIdFilter”, urlPatterns = “/*”)
@Order(Integer.MIN_VALUE)
public class LogRequestIdFilter implements Filter {

public static final String TRACE_ID = "traceId";
public static final String IP = "ip";

public static final String CLIENT_INFO = "client_info";



@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
    HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
    String requestId = httpRequest.getHeader(TRACE_ID);
    String userAgent = httpRequest.getHeader(Header.USER_AGENT.getValue());
    String realIp = RemortIPUtil.getRealIp(httpRequest);
    if (requestId == null) {
        requestId = UUID.randomUUID().toString();
    }
    MDC.put(TRACE_ID, requestId);
    MDC.put(IP,realIp);
    MDC.put(CLIENT_INFO,userAgent);
    httpResponse.setHeader(TRACE_ID, requestId);
    try {
        filterChain.doFilter(servletRequest, servletResponse);
    } finally {
        MDC.clear();
    }

}

}



import lombok.extern.slf4j.Slf4j;

import javax.servlet.http.HttpServletRequest;
import java.util.regex.Pattern;

@Slf4j
public class RemortIPUtil {
// 将长Ip截取
public static String getRemortIP(HttpServletRequest request) {
if (request.getHeader(“x-forwarded-for”) == null) {
return request.getRemoteAddr();
}
// 获得反向代理IP
String ip = request.getHeader(“x-forwarded-for”);
if (null!=ip&&“”!=ip) {
if (ip.indexOf(“,”) > -1) {
ip = ip.substring(0, ip.indexOf(“,”));
}
}

    return ip;
}

public static String getRemortIPLong(HttpServletRequest request) {
    String ip = request.getHeader("x-forwarded-for");
    if(ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("Proxy-Client-IP");
    }
    if(ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {

最后

小编在这里分享些我自己平时的学习资料,由于篇幅限制,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!

开源分享:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】

程序员代码面试指南 IT名企算法与数据结构题目最优解

这是” 本程序员面试宝典!书中对IT名企代码面试各类题目的最优解进行了总结,并提供了相关代码实现。针对当前程序员面试缺乏权威题目汇总这一-痛点, 本书选取将近200道真实出现过的经典代码面试题,帮助广“大程序员的面试准备做到万无一失。 “刷”完本书后,你就是“题王”!

image.png

《TCP-IP协议组(第4版)》

本书是介绍TCP/IP协议族的经典图书的最新版本。本书自第1版出版以来,就广受读者欢迎。

本书最新版进行」护元,以体境计算机网络技不的最新发展,全书古有七大部分共30草和7个附录:第一部分介绍一些基本概念和基础底层技术:第二部分介绍网络层协议:第三部分介绍运输层协议;第四部分介绍应用层协议:第五部分介绍下一代协议,即IPv6协议:第六部分介绍网络安全问题:第七部分给出了7个附录。

image.png

Java开发手册(嵩山版)

这个不用多说了,阿里的开发手册,每次更新我都会看,这是8月初最新更新的**(嵩山版)**

image.png

MySQL 8从入门到精通

本书主要内容包括MySQL的安装与配置、数据库的创建、数据表的创建、数据类型和运算符、MySQL 函数、查询数据、数据表的操作(插入、更新与删除数据)、索引、存储过程和函数、视图、触发器、用户管理、数据备份与还原、MySQL 日志、性能优化、MySQL Repl ication、MySQL Workbench、 MySQL Utilities、 MySQL Proxy、PHP操作MySQL数据库和PDO数据库抽象类库等。最后通过3个综合案例的数据库设计,进步讲述 MySQL在实际工作中的应用。

image.png

Spring5高级编程(第5版)

本书涵盖Spring 5的所有内容,如果想要充分利用这一领先的企业级 Java应用程序开发框架的强大功能,本书是最全面的Spring参考和实用指南。

本书第5版涵盖核心的Spring及其与其他领先的Java技术(比如Hibemate JPA 2.Tls、Thymeleaf和WebSocket)的集成。本书的重点是介绍如何使用Java配置类、lambda 表达式、Spring Boot以及反应式编程。同时,将与企业级应用程序开发人员分享一些见解和实际经验,包括远程处理、事务、Web 和表示层,等等。

image.png

JAVA核心知识点+1000道 互联网Java工程师面试题

image.png

image.png

企业IT架构转型之道 阿里巴巴中台战略思想与架构实战

本书讲述了阿里巴巴的技术发展史,同时也是-部互联网技 术架构的实践与发展史。

image.png

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

本书第5版涵盖核心的Spring及其与其他领先的Java技术(比如Hibemate JPA 2.Tls、Thymeleaf和WebSocket)的集成。本书的重点是介绍如何使用Java配置类、lambda 表达式、Spring Boot以及反应式编程。同时,将与企业级应用程序开发人员分享一些见解和实际经验,包括远程处理、事务、Web 和表示层,等等。

[外链图片转存中…(img-NTs7dlMZ-1715298794517)]

JAVA核心知识点+1000道 互联网Java工程师面试题

[外链图片转存中…(img-JWkDmJHQ-1715298794517)]

[外链图片转存中…(img-efcdPQdu-1715298794518)]

企业IT架构转型之道 阿里巴巴中台战略思想与架构实战

本书讲述了阿里巴巴的技术发展史,同时也是-部互联网技 术架构的实践与发展史。

[外链图片转存中…(img-g3YXt71J-1715298794518)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

需要这份系统化的资料的朋友,可以点击这里获取

  • 27
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值