Mybatis 源码学习(1)-解析器模块

XPath

XPath 是通过表达式在 XML 文件中选择节点的表达式语言,通过 XPath 可以将 XML 中的节点,通过特定的规则将 DOM 对象,转化为 Boolean、Double、String 等 Java 对象。

表达式含义
nodename选取指定节点的子节点
/从根节点开始选取指定节点
//根据指定的表达式,在整个文档中选取匹配的节点(不考虑节点的位置)
.选取当前节点
选取当前节点的父节点
@选取属性
*匹配任意节点元素
@*匹配任意属性节点
node()匹配任何类型的节点
text()匹配文本节点
|选取若干路径
[]指定某个条件,用于查找某个特定节点

简单的举例来说,给定如下 xml 文件内容:

<foo>
  <bar/>
  <bar/>
  <bar/>
</foo>

选择所有的bar 元素://bar
使用通配符选择所有的 foo 元素的子元素:/foo/*
根据 include 属性值选择 foo 元素://foo[@include='true']

Xpath 的核心类包括:

XPath:提供利用 xpath 语法进行处理的入口;
Node:根据选择表达式获取的单个元素节点(如果命中多个节点,node.getNextSibling 会指向后续的节点,直至最后一个);
NodeList:Node的列表;
XPathConstants:作为 XPath.evaluate 的参数,标记 Node 的类型,XPathConstants.NODESET(节点集合,对应类型为 NodeList)、XPathConstants.NODE(节点,对应类型为 Node);XPathConstants.STRING(字符串值,对应类型为String);XPathConstants.BOOLEAN(布尔值,对应类型为 Boolean);XPathConstants.NUMBER(数值,对应类型为 Double);
XPathExpression:预编译的 xpath 表达式,可以被多次使用,方便提高性能。

举例来看:

public class XPathExample {
    private static final String xml =
            "<inventory>\n" +
            "    <book year=\"2000\">\n" +
            "        <title>Snow Crash</title>\n" +
            "        <author>Neal Stephenson</author>\n" +
            "        <publisher>Spectra</publisher>\n" +
            "        <isbn>0553380958</isbn>\n" +
            "        <price>14.95</price>\n" +
            "    </book>\n" +
            "    <book year=\"2005\">\n" +
            "        <title>Burning Tower</title>\n" +
            "        <author>Larry Niven</author>\n" +
            "        <author>Jerry Pournelle</author>\n" +
            "        <publisher>Pocket</publisher>\n" +
            "        <isbn>0743416910</isbn>\n" +
            "        <price>5.99</price>\n" +
            "    </book>\n" +
            "    <book year=\"1995\">\n" +
            "        <title>Zodiac</title>\n" +
            "        <author>Neal Stephenson</author>\n" +
            "        <publisher>Spectra</publisher>\n" +
            "        <isbn>0553573862</isbn>\n" +
            "        <price>7.50</price>\n" +
            "    </book>\n" +
            "</inventory>";

    // 参考文档:https://docs.oracle.com/javase/10/docs/api/javax/xml/xpath/package-summary.html
    // 直接使用 xpath 读取文件内容
    @Test
    public void javaSDKEg() throws Exception {
        // parse the XML as a W3C Document
        DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
        Document document = builder.parse(new InputSource(new StringReader(xml)));

        //Get an XPath object and evaluate the expression
        XPath xpath = XPathFactory.newInstance().newXPath();
        String expression = "/inventory/book";
        Node widgetNode = (Node) xpath.evaluate(expression, document, XPathConstants.NODE);

        System.out.println(widgetNode.getNodeName());
        // log.info("{}", JsonUtils.serialize(widgetNode));
    }

    @Test
    public void book() throws Exception {
        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();

        // 开启验证
        // documentBuilderFactory.setValidating(true);
        documentBuilderFactory.setNamespaceAware(false);
        documentBuilderFactory.setIgnoringComments(true);
        documentBuilderFactory.setIgnoringElementContentWhitespace(false);
        documentBuilderFactory.setCoalescing(false);
        documentBuilderFactory.setExpandEntityReferences(true);

        // 创建 DocumentBuilder
        DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder(); //设置异常处理对象
        builder.setErrorHandler(new ErrorHandler() {
            @Override
            public void error(SAXParseException exception) throws SAXException {
                System.out.println("error:" + exception.getMessage());
            }

            @Override
            public void fatalError(SAXParseException exception) throws SAXException {
                System.out.println("fatalError:" + exception.getMessage());
            }

            @Override
            public void warning(SAXParseException exception) throws SAXException {
                System.out.println("WARN:" + exception.getMessage());
            }
        });

        // 将文档加载到一个 Document 对象中
        Document doc = builder.parse(new InputSource(new StringReader(xml)));

        // 创建 XPathFactory
        XPathFactory factory = XPathFactory.newInstance(); //创建 XPath 对象
        XPath xpath = factory.newXPath();

        // 编译 XPath 表达式
        XPathExpression expr =
                xpath.compile("//book[author='Neal Stephenson']/title/text()");

        // 通过XPath表达式得到结果,第一个参数指定了 XPath 表达式进行查询的上下文节点,也就是在指定
        // 节点下查找符合 XPath 的节点。本例中的上下文节点是整个文档;第二个参数指定了 XPath 表达式
        // 的返回类型 。
        Object result = expr.evaluate(doc, XPathConstants.NODESET);
        System.out.println("查询作者为 Neal Stephenson 的图书的标题:");
        NodeList nodes = (NodeList) result; // 强制类型转换
        for (int i = 0; i < nodes.getLength(); i++) {
            System.out.println(nodes.item(i).getNodeValue());
        }

        System.out.println("查询 1997 年之后的图书的标题:");
        nodes = (NodeList) xpath.evaluate("//book[@year>1997]/title/text()", doc, XPathConstants.NODESET);
        for (int i = 0; i < nodes.getLength(); i++) {
            System.out.println(nodes.item(i).getNodeValue());
        }

        System.out.println("查询 1997 年之后的图书的属性和标题:");
        nodes = (NodeList) xpath
                .evaluate("//book[@year>1997]/@* | //book[@year>1997]/title/text()", doc, XPathConstants.NODESET);
        for (int i = 0; i < nodes.getLength(); i++) {
            System.out.println(nodes.item(i).getNodeValue());
        }
    }
}

总的来说,XPath 是使用选择器选择 XML 文件内元素的工具,在 Mybatis 中解析 config 和 mapper 文件均使用该工具。

XPathParser

XPathParser 提供了对 XML 文件解析的能力,它将 XPath、Document 对象做了封装,便于进行对象操作,此外,XPathParser 还对原始方法做了升级,使得原本只能处理 String、Integer、Double 类型的数据,升级为能够处理 String、Boolean、Short、Integer、Long、Float、Double。

public class XPathParser {

  private final Document document; // Document 对象
  private boolean validation; // 是否开启验证
  private EntityResolver entityResolver; // 用于加载本地 DTD 文件
  private Properties variables; // mybatis-config.xml 中<propteries>标签定义的键位对集合
  private XPath xpath; // XPath 对象 

  public XPathParser() { // 多个重载的构造函数,用于加载 xml 文件
    commonConstructor(); // 通用构造器
    this.document = createDocument(); // 创建 Document 对象
  }

  // 设置外界参数,同时初始化 XPath 对象
  private void commonConstructor(boolean validation, Properties variables, EntityResolver entityResolver) {
    this.validation = validation;
    this.entityResolver = entityResolver;
    this.variables = variables;
    XPathFactory factory = XPathFactory.newInstance();
    this.xpath = factory.newXPath(); // 初始化 XPath 对象
  }

  // 调用 createDocument ()方法之前一定要先调用 commonConstructor()方法完成初始化 
  private Document createDocument(InputSource inputSource) {
    try {
      //创建 DocumentBuilderFactory 对象
      DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
      // 为 Document 设置验证参数
      factory.setValidating(validation);
      factory.setNamespaceAware(false);
      factory.setIgnoringComments(true);
      factory.setIgnoringElementContentWhitespace(false);
      factory.setCoalescing(false);
      factory.setExpandEntityReferences(true);

      //创建 DocumentBuilder 对象并进行配置
      DocumentBuilder builder = factory.newDocumentBuilder();
      //设置 EntityResolver 接口对象 
      builder.setEntityResolver(entityResolver);
      // 设置 ErrorHandler  处理器,所有的方法都是空方法
      builder.setErrorHandler(new ErrorHandler() {});
      //加载 XML 文件,构造 Document 对象
      return builder.parse(inputSource);
    } catch (Exception e) {
      throw new BuilderException("Error creating document instance.  Cause: " + e, e);
    }
  }

  public void setVariables(Properties variables) // 设置外部 properties

  public String evalString() // 重载函数,处理 String
  public Boolean evalBoolean() // 重载函数,处理 Boolean
  public Short evalShort() // 重载函数,处理 Short(通过 Short.valueOf(evalString(…)))
  public Integer evalInteger()  // 重载函数,处理 Integer(通过 Integer.valueOf(evalString(…))
  public Long evalLong() // 重载函数,处理 Long(通过 Long.valueOf(evalString(…))
  public Float evalFloat()  // 重载函数,处理 Float(通过 Float.valueOf(evalString(…))
  public Double evalDouble() // 重载函数,处理 Double

  public List<XNode> evalNodes()  // 重载函数,将原始 NodeList 构造为 List<XNode>
  public XNode evalNode() // 重载函数,将原始 Node 构造为 XNode
}

从上边的代码可以看出,初始化 XPathParser 时,会通过 createDocument 方法触发对 XML 文件的加载,并创建 Document 对象,此外在调用 createDocument 之前会要求必须执行 commonConstructor 方法以初始化 xpath 对象。除了初始化部分,XPathParser 提供了一系列 eval* 方法,这些方法被用于解析特定的数据类型,它们背后实际上调用的还是 xpath.evaluate(…) 方法。

总的来说,XPathParser 是Mybatis 中解析 XML 文件的工具,它封装了 XPath 的方法,使得解析的代码更加清晰、简单。

EntityResolver

默认情况下,在对 XML 文件进行验证时,会从网络下载 XML 文件对应的 DTD 文件或 XSD 文件,如,解析 mybatis-config.xml 时会从网络下载 http://mybatis.org/dtd/mybatis-3-config.dtd,但是某些情况下,运行时,是没有网络,或者每次都网络下载,其开销也是非常大的,为了避免从网络下载验证文件,则需要使用 EntityResolver 加载本地验证文件。XMLMapperEntityResolver 是 MyBatis提供的 EntityResolver 接口的实现类。

EntityResolver 接口中仅存在一个方法:resolveEntity,程序会在读取任何外部资源之前,都会首先调用该方法。XMLMapperEntityResolver 中设置了网络文件与本地 jar 包中的 DTD 文件的路径对应关系,在加载文件时,会自动用本地文件替代网络文件。

public class XMLMapperEntityResolver implements EntityResolver {

  // //指定 mybatis-config.xml 文件和映射文件与对应的 DTD 的 SystemId 
  private static final String IBATIS_CONFIG_SYSTEM = "ibatis-3-config.dtd";
  private static final String IBATIS_MAPPER_SYSTEM = "ibatis-3-mapper.dtd";
  private static final String MYBATIS_CONFIG_SYSTEM = "mybatis-3-config.dtd";
  private static final String MYBATIS_MAPPER_SYSTEM = "mybatis-3-mapper.dtd";

  //指定 mybatis-config.xml 文件和映射文件对应的 DTD 文件的具体位置 
  private static final String MYBATIS_CONFIG_DTD = "org/apache/ibatis/builder/xml/mybatis-3-config.dtd";
  private static final String MYBATIS_MAPPER_DTD = "org/apache/ibatis/builder/xml/mybatis-3-mapper.dtd";

  // resolveEntity ()方法是 EntityResolver 接 口中 定义的方法,
  public InputSource resolveEntity(String publicId, String systemId) throws SAXException {
    try {
      if (systemId != null) {
        String lowerCaseSystemId = systemId.toLowerCase(Locale.ENGLISH);
        //查找 systemId 指定的 DTD 文档 , 并调用 getInputSource ()方法读取 DTD 文档 
        if (lowerCaseSystemId.contains(MYBATIS_CONFIG_SYSTEM) || lowerCaseSystemId.contains(IBATIS_CONFIG_SYSTEM)) {
          return getInputSource(MYBATIS_CONFIG_DTD, publicId, systemId);
        } else if (lowerCaseSystemId.contains(MYBATIS_MAPPER_SYSTEM) || lowerCaseSystemId.contains(IBATIS_MAPPER_SYSTEM)) {
          return getInputSource(MYBATIS_MAPPER_DTD, publicId, systemId);
        }
      }
      return null;
    } catch (Exception e) {
      throw new SAXException(e.toString());
    }
  }

  // 读取 DTD 文档并形成 InputSource 对象 
  private InputSource getInputSource(String path, String publicId, String systemId) {
    InputSource source = null;
    if (path != null) {
      try {
        InputStream in = Resources.getResourceAsStream(path);
        source = new InputSource(in);
        source.setPublicId(publicId);
        source.setSystemId(systemId);        
      } catch (IOException e) {
      }
    }
    return source;
  }
}

总的来说,EntityResolver 是用来用本地 DTD 文件替换远程网络文件,辅助验证 XML 文件的工具。

PropertyParser

在 XPathParser.evalString 方法中,存在对 PropertyParser 方法的调用,用于对节点的默认值进行处理。

public String evalString(Object root, String expression) {
  String result = (String) evaluate(expression, root, XPathConstants.STRING);
  result = PropertyParser.parse(result, variables); // 对节点的默认值作处理
  return result;
}

在 PropertyParser 中指定了是否开启使用默认值的功能以及默认的分隔符。

private static final String KEY_PREFIX = "org.apache.ibatis.parsing.PropertyParser.";

// 在 mybatis-config.xml 中<properties>节点下是否开启默认值功能的对应配置项 
public static final String KEY_ENABLE_DEFAULT_VALUE = KEY_PREFIX + "enable-default-value";
// 配置占位符与默认值之间的默认分隔符的对应配置项
public static final String KEY_DEFAULT_VALUE_SEPARATOR = KEY_PREFIX + "default-value-separator";

// 默认情况下,关闭默认值的功能 
private static final String ENABLE_DEFAULT_VALUE = "false";
// 默认分隔符是冒号
private static final String DEFAULT_VALUE_SEPARATOR = ":";

PropertyParser.parse 方法的解析过程交由 GenericTokenParser 解析器去解析,而 GenericTokenParser 是通用的 token 解析器,用于根据参数,将特定的占位符去除并用属性值替换。

public static String parse(String string, Properties variables) {
  VariableTokenHandler handler = new VariableTokenHandler(variables);
  // 创建 GenericTokenParser 对象,并指定其处理的占位符格式为”${ }” 
  GenericTokenParser parser = new GenericTokenParser("${", "}", handler);
  return parser.parse(string);
}

对于 GenericTokenParser 而言,它的核心方法就是 parse 方法,它会根据 openToken 和 closeToken 在待解析的字符串中使用 TokenHandler 处理占位符,处理完成后,并重构成新的字符串。

public class GenericTokenParser {
   // … 初始化方法

  public String parse(String text) {
    // … 判断 text 是否是空的

    // 检查 openToken 是否在 text 中出现过,未出现(indexOf == -1)则直接返回 text
    int start = text.indexOf(openToken, 0);
    if (start == -1) {
      return text;
    }

    char[] src = text.toCharArray();
    int offset = 0;
    // 用于记录解析后的字符串
    final StringBuilder builder = new StringBuilder();
    // 用来记录一个占位符的字面值
    StringBuilder expression = null;
    while (start > -1) {
      if (start > 0 && src[start - 1] == '\\') {
        // 遇到转义的开始标记,则直接将前面的字符串以及开始标记追加到 builder 中 
        builder.append(src, offset, start - offset - 1).append(openToken);
        offset = start + openToken.length();
      } else {
        // 查找到开始标记,且未转义
        if (expression == null) {
          expression = new StringBuilder();
        } else {
          // 如果已经存在 expression 则重置 expression 的长度,使得 expression 的数据为空
          expression.setLength(0);
        }
        // 将前面的字符串追加到 builder 中,其中 offset 是上次的开始下标,start 本次查询到的位置
        builder.append(src, offset, start - offset);
        // 修改 offset 的位置,设置为 openToken 的位置 + openToken.length,即 openToken 之后的第一个位置
        offset = start + openToken.length();
        // 从 offset 向后查询 closeToken
        int end = text.indexOf(closeToken, offset);
        while (end > -1) {
          if (end > offset && src[end - 1] == '\\') {
            // 处理转义的结束标记,并去除转义字符
            expression.append(src, offset, end - offset - 1).append(closeToken);
            offset = end + closeToken.length();
            // 继续向后搜索 closeToken
            end = text.indexOf(closeToken, offset);
          } else {
            // 将开始标记和结束标记之间的字符串追加到 expression 中保存 
            expression.append(src, offset, end - offset);
            offset = end + closeToken.length();
            break;
          }
        }
        if (end == -1) {
          // 未找到结束标记,将剩余字符串全部存储至 builder
          builder.append(src, start, src.length - start);
          offset = src.length;
        } else {
          // 将占位符的字面值交给 TokenHandler 处理,并将处理结果追加到 builder 中保存
          // 最终拼凑出解析后的完整内容
          builder.append(handler.handleToken(expression.toString()));
          offset = end + closeToken.length();
        }
      }
      // 继续向后搜索,注意 offset,如果 offset 是 test 的结尾,start 必定是 -1,即不会有后续的遍历
      start = text.indexOf(openToken, offset);
    }
    // 处理最后剩余的字符串
    if (offset < src.length) {
      builder.append(src, offset, src.length - offset);
    }
    return builder.toString();
  }
}

TokenHandler 是专门解析占位符的工具类,它共有 4 种实现,每种实现均是内部类的方式。在 PropertyParser 中,使用它的内部类 VariableTokenHandler 进行占位符解析。
在这里插入图片描述
TokenHandler 中的 handleToken 方法是它的子类的唯一逻辑,VariableTokenHandler 提供了该方法。VariableTokenHandler 的 handleToken 方法

private static class VariableTokenHandler implements TokenHandler {
  private final Properties variables; // <properties>节点下定义的键值对,用于替换占位符 
  private final boolean enableDefaultValue; // 是否支持占位符中使用默认值的功能
  private final String defaultValueSeparator; // 指定占位符和默认值之间的分隔符 

  private VariableTokenHandler(Properties variables) {
    this.variables = variables;
    // 获取 <properties> 节点的设置
    this.enableDefaultValue = Boolean.parseBoolean(getPropertyValue(KEY_ENABLE_DEFAULT_VALUE, ENABLE_DEFAULT_VALUE));
    this.defaultValueSeparator = getPropertyValue(KEY_DEFAULT_VALUE_SEPARATOR, DEFAULT_VALUE_SEPARATOR);
  }

  // 根据 key 获取设置
  private String getPropertyValue(String key, String defaultValue) {
    return (variables == null) ? defaultValue : variables.getProperty(key, defaultValue);
  }

  public String handleToken(String content) {
    if (variables != null) {
      String key = content;
      // 检测是否支持占位符中使用默认值的功能 
      if (enableDefaultValue) {
        // 查找分隔符
        final int separatorIndex = content.indexOf(defaultValueSeparator);
        String defaultValue = null;
        if (separatorIndex >= 0) {
          // 获取占位符的名称
          key = content.substring(0, separatorIndex);
          // 分割符后面的字符串即默认值
          defaultValue = content.substring(separatorIndex + defaultValueSeparator.length());
        }
        if (defaultValue != null) {
          // 在 variables 集合中查找指定的占位符
          return variables.getProperty(key, defaultValue);
        }
      }
      // 不支持默认值的功能,则直接查找 variables 集合 
      if (variables.containsKey(key)) {
        return variables.getProperty(key);
      }
    }
    // 无 variables 集合,直接返回(这里是因为 VariableTokenHandler 和 GenericTokenParser 需要配合使用才能这么返回)
    return "${" + content + "}";
  }
}

对 PropertyParser 举例而言,如果存在配置${userName:root},此时占位符是:,PropertyParser 会在 <properties> 节点下的 variables 集合中寻找 key userName,如果存在,则使用 variables 指定的值,否则使用 root 作为默认值。

需要注意的是, GenericTokenParser 不仅仅用于默认值解析,还会用于动态 SQL 语句的解析。GenericTokenParser 只是查找到指定的占位符,而具体的解析行为会交给 TokenHandler,这是一种策略模式。

总结而言,PropertyParser 可以用来处理 XML 文件中的默认值,它可以使用 GenericTokenParser 在上下文中搜索待匹配的通配符,并使用 TokenHandler 处理通配符中的内容,将内容中的属性 key 和默认值分割,并从 properties 标签中获取默认值(properties 不存在默认值时,使用内容中的默认值)。

XNode

在 XPathParser 进行节点解析时(执行 XPathParser.evalNode)返回的是 XNode 对象,而不是 XPath 的原始 Node 对象。XNode 是对 Node 对象的封装,方便解析对应节点的 attribute 和 value。

public class XNode {

  private final Node node; // org.w3c.dom.Node 对象
  private final String name; // Node 节点名称
  private final String body; // 节点的内容
  private final Properties attributes; // 节点属性集合
  private final Properties variables; // mybatis-config.xrnl 配置文件中<properties>节点下定义的键位对
  private final XPathParser xpathParser; // XPathParser 对象,该 XNode 对象由此 XPathParser 对象生成 

  public XNode(XPathParser xpathParser, Node node, Properties variables) {
    this.xpathParser = xpathParser;
    this.node = node;
    this.name = node.getNodeName();
    this.variables = variables;
    this.attributes = parseAttributes(node); // 初始化节点属性
    this.body = parseBody(node); // 初始化节点内容
  }

  private Properties parseAttributes(Node n) {
    Properties attributes = new Properties();
    // 获取节点的属性集合
    NamedNodeMap attributeNodes = n.getAttributes();
    if (attributeNodes != null) {
      for (int i = 0; i < attributeNodes.getLength(); i++) {
        Node attribute = attributeNodes.item(i);
        // 使用 PropertyParser 处理每个属性 中的占位符(因为节点属性允许使用属性变量设置)
        String value = PropertyParser.parse(attribute.getNodeValue(), variables);
        attributes.put(attribute.getNodeName(), value);
      }
    }
    return attributes;
  }

  private String parseBody(Node node) {
    String data = getBodyData(node); // 节点本身即文本节点
    // 当前节点不是文本节点
    if (data == null) {
      NodeList children = node.getChildNodes(); // 处理子节点
      for (int i = 0; i < children.getLength(); i++) {
        Node child = children.item(i);
        // 返回第一个文本节点(其实应该只会有一个文本节点)
        data = getBodyData(child);
        if (data != null) {
          break;
        }
      }
    }
    return data;
  }

  private String getBodyData(Node child) {
    // 只处理文本内容
    if (child.getNodeType() == Node.CDATA_SECTION_NODE
        || child.getNodeType() == Node.TEXT_NODE) {
      String data = ((CharacterData) child).getData();
      // 使用 PropertyParser 处理文本节点中的占位符
      data = PropertyParser.parse(data, variables);
      return data;
    }
    return null;
  }

  // … 其他方法
}

XNode 中提供了多种 get*()方法获取所需的节点信息,这些信息主要来自上面介绍的 attribute 集合、body宇段、node宇段 。

总结

在这里插入图片描述
XPath 是解析 XML 文件的一种方式,它可以很方便的从 DOM 树中选择指定的节点。Mybatis 提供了对 XPath 的封装 XPathParser,它可以更容易的将 XPath 中搜索到的节点转化为基本数据类型。在 XPathParser 中会使用 EntityResolver 处理对 DTD 验证文件的读取,避免直接从网络获取 DTD 文件,Mybatis 的 XMLMapperEntityResolver 提供了对 mybatis-3-config.dtdmybatis-3-mapper.dtd 的支持。PropertyParser 可以用来处理 XML 文件中的默认值,它使用 GenericTokenParser 遍历待匹配字符,使用 VariableTokenHandler 实际处理每个通配符内的内容。XNode 是对 org.w3c.dom.Node 对象的封装,它提供了对节点内容和属性的封装,便于实际使用和操作节点。


参考文档:《Mybatis 技术内幕》

本文的基本脉络参考自《Mybatis 技术内幕》,编写文章的原因是希望能够系统地学习 Mybatis 的源码,但是如果仅阅读源码或者仅从官方文档很难去系统地学习,因此希望参考现成的文档,按照文章的脉络逐步学习。


欢迎关注我的公众号:我的搬砖日记,我会定时分享自己的学习历程。
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值