zoukankan      html  css  js  c++  java
  • mybatis源码分析(二)------------配置文件的解析

    这篇文章中,我们将讲解配置文件中 properties,typeAliases,settings和environments这些节点的解析过程。

    一 properties的解析

     private void propertiesElement(XNode context) throws Exception {
        if (context != null) {
    //解析properties的子节点,并将这些子节点内容转为属性对象Properties Properties defaults
    = context.getChildrenAsProperties();
    //获取properties节点中resource的属性值 String resource
    = context.getStringAttribute("resource");
    //获取properties节点中url的属性值 String url
    = context.getStringAttribute("url");
    //resource和url不能同时存在
    if (resource != null && url != null) { throw new BuilderException("The properties element cannot specify both a URL and a resource based property file reference. Please specify one or the other."); } if (resource != null) {
    // 从文件系统中加载并解析属性文件,此时会覆盖在xml中配置的properties子节点的同名key-value defaults.putAll(Resources.getResourceAsProperties(resource)); }
    else if (url != null) {
    // 从url中解析并加载属性文件 defaults.putAll(Resources.getUrlAsProperties(url)); }
    // 获取configuration中已经定义的属性对象Properties Properties vars
    = configuration.getVariables(); if (vars != null) {
    // configuration中的key-value会覆盖上面两种情况中的key-value defaults.putAll(vars); }
    // 将解析出的内容set到parser中 parser.setVariables(defaults);
    // 将解析出的内容set到configuration中,configuration会装载所解析的配置文件中所有的节点内容,后面会使用到这个对象 configuration.setVariables(defaults); } }

     二 settings的解析

    先看下settings的配置,下面只是settings配置中的一部分:

    <!-- settings是 MyBatis 中极为重要的调整设置,它们会改变 MyBatis 的运行时行为。 -->
        <settings>
            <!-- 该配置影响的所有映射器中配置的缓存的全局开关。默认值true -->
            <setting name="cacheEnabled" value="true"/>
            <!--延迟加载的全局开关。当开启时,所有关联对象都会延迟加载。 特定关联关系中可通过设置fetchType属性来覆盖该项的开关状态。默认值false  -->
            <setting name="lazyLoadingEnabled" value="true"/>
            <!-- 是否允许单一语句返回多结果集(需要兼容驱动)。 默认值true -->
            <setting name="multipleResultSetsEnabled" value="true"/>
        </settings>

    源码部分:

     private void settingsElement(XNode context) throws Exception {
        if (context != null) {
    // 获取settings节点下所有的子节点信息,然后封装成Properties对象 Properties props
    = context.getChildrenAsProperties(); // Check that all settings are known to the configuration class
    // 获取Configuration的元信息对象
    MetaClass metaConfig = MetaClass.forClass(Configuration.class); for (Object key : props.keySet()) {
    // 检测Configuration中是否存在相关的属性,如果不存在,那么抛出异常
    if (!metaConfig.hasSetter(String.valueOf(key))) { throw new BuilderException("The setting " + key + " is not known. Make sure you spelled it correctly (case sensitive)."); } }
    //以下是把获取到的setting信息封装到Configuration中,所以才需要上面的检测 configuration.setAutoMappingBehavior(AutoMappingBehavior.valueOf(props.getProperty(
    "autoMappingBehavior", "PARTIAL"))); configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true)); configuration.setProxyFactory((ProxyFactory) createInstance(props.getProperty("proxyFactory"))); configuration.setLazyLoadingEnabled(booleanValueOf(props.getProperty("lazyLoadingEnabled"), false)); configuration.setAggressiveLazyLoading(booleanValueOf(props.getProperty("aggressiveLazyLoading"), true)); configuration.setMultipleResultSetsEnabled(booleanValueOf(props.getProperty("multipleResultSetsEnabled"), true)); configuration.setUseColumnLabel(booleanValueOf(props.getProperty("useColumnLabel"), true)); configuration.setUseGeneratedKeys(booleanValueOf(props.getProperty("useGeneratedKeys"), false)); configuration.setDefaultExecutorType(ExecutorType.valueOf(props.getProperty("defaultExecutorType", "SIMPLE"))); configuration.setDefaultStatementTimeout(integerValueOf(props.getProperty("defaultStatementTimeout"), null)); configuration.setMapUnderscoreToCamelCase(booleanValueOf(props.getProperty("mapUnderscoreToCamelCase"), false)); configuration.setSafeRowBoundsEnabled(booleanValueOf(props.getProperty("safeRowBoundsEnabled"), false)); configuration.setLocalCacheScope(LocalCacheScope.valueOf(props.getProperty("localCacheScope", "SESSION"))); configuration.setJdbcTypeForNull(JdbcType.valueOf(props.getProperty("jdbcTypeForNull", "OTHER"))); configuration.setLazyLoadTriggerMethods(stringSetValueOf(props.getProperty("lazyLoadTriggerMethods"), "equals,clone,hashCode,toString")); configuration.setSafeResultHandlerEnabled(booleanValueOf(props.getProperty("safeResultHandlerEnabled"), true)); configuration.setDefaultScriptingLanguage(resolveClass(props.getProperty("defaultScriptingLanguage"))); configuration.setCallSettersOnNulls(booleanValueOf(props.getProperty("callSettersOnNulls"), false)); configuration.setLogPrefix(props.getProperty("logPrefix")); configuration.setLogImpl(resolveClass(props.getProperty("logImpl"))); configuration.setConfigurationFactory(resolveClass(props.getProperty("configurationFactory"))); } }

     二 typeAliases的解析

    先看下在配置文件中的写法:

        <typeAliases>
            <typeAlias alias="goods" type="com.yht.mybatisTest.entity.Goods"/>
        </typeAliases>

    源码部分:

     private void typeAliasesElement(XNode parent) {
        if (parent != null) {
    // 循环处理typeAliases下所有的子节点
    for (XNode child : parent.getChildren()) {
    // 这是针对子节点是package的处理
    if ("package".equals(child.getName())) { String typeAliasPackage = child.getStringAttribute("name"); configuration.getTypeAliasRegistry().registerAliases(typeAliasPackage); } else {
    // 如果子节点是typeAlias String alias
    = child.getStringAttribute("alias"); //获取alias的属性值 String type = child.getStringAttribute("type"); //获取type的属性值 try {
    // 获取type对应的类型 Class
    <?> clazz = Resources.classForName(type); if (alias == null) {
    // 注册别名到类型的映射 进入该方法 typeAliasRegistry.registerAlias(clazz); }
    else { typeAliasRegistry.registerAlias(alias, clazz); } } catch (ClassNotFoundException e) { throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e); } } } } }

    进入registerAlias方法:

      public void registerAlias(Class<?> type) {
    // 如果在配置文件中没有配置alias属性,这里会获取type的类名 String alias
    = type.getSimpleName();
    // 获取注解上的别名 Alias aliasAnnotation
    = type.getAnnotation(Alias.class); if (aliasAnnotation != null) { alias = aliasAnnotation.value(); }
    // 进入此方法 registerAlias(alias, type); }
      public void registerAlias(String alias, Class<?> value) {
        if (alias == null) throw new TypeException("The parameter alias cannot be null");
    // 将别名转为小写 String key
    = alias.toLowerCase(Locale.ENGLISH); // issue #748
    // 如果TYPE_ALIASES已经存在该别名,并且对应的类型不为空,同时已经存在的类型不等于将要注册的类型value,那么抛出异常 if (TYPE_ALIASES.containsKey(key) && TYPE_ALIASES.get(key) != null && !TYPE_ALIASES.get(key).equals(value)) { throw new TypeException("The alias '" + alias + "' is already mapped to the value '" + TYPE_ALIASES.get(key).getName() + "'."); }
    // 将别名和对应的全限定名放入到HashMap中,TYPE——ALIASES是一个HashMap TYPE_ALIASES.put(key, value); }
    接着看下Mybatis内部常见别名注册:
     private final Map<String, Class<?>> TYPE_ALIASES = new HashMap<String, Class<?>>();
    
      public TypeAliasRegistry() {
        registerAlias("string", String.class);
    
        registerAlias("byte", Byte.class);
        registerAlias("long", Long.class);
        registerAlias("short", Short.class);
        registerAlias("int", Integer.class);
        registerAlias("integer", Integer.class);
        registerAlias("double", Double.class);
        registerAlias("float", Float.class);
        registerAlias("boolean", Boolean.class);
    
        registerAlias("byte[]", Byte[].class);
        registerAlias("long[]", Long[].class);
        registerAlias("short[]", Short[].class);
        registerAlias("int[]", Integer[].class);
        registerAlias("integer[]", Integer[].class);
        registerAlias("double[]", Double[].class);
        registerAlias("float[]", Float[].class);
        registerAlias("boolean[]", Boolean[].class);
    
        registerAlias("_byte", byte.class);
        registerAlias("_long", long.class);
        registerAlias("_short", short.class);
        registerAlias("_int", int.class);
        registerAlias("_integer", int.class);
        registerAlias("_double", double.class);
        registerAlias("_float", float.class);
        registerAlias("_boolean", boolean.class);
    
        registerAlias("_byte[]", byte[].class);
        registerAlias("_long[]", long[].class);
        registerAlias("_short[]", short[].class);
        registerAlias("_int[]", int[].class);
        registerAlias("_integer[]", int[].class);
        registerAlias("_double[]", double[].class);
        registerAlias("_float[]", float[].class);
        registerAlias("_boolean[]", boolean[].class);
    
        registerAlias("date", Date.class);
        registerAlias("decimal", BigDecimal.class);
        registerAlias("bigdecimal", BigDecimal.class);
        registerAlias("biginteger", BigInteger.class);
        registerAlias("object", Object.class);
    
        registerAlias("date[]", Date[].class);
        registerAlias("decimal[]", BigDecimal[].class);
        registerAlias("bigdecimal[]", BigDecimal[].class);
        registerAlias("biginteger[]", BigInteger[].class);
        registerAlias("object[]", Object[].class);
    
        registerAlias("map", Map.class);
        registerAlias("hashmap", HashMap.class);
        registerAlias("list", List.class);
        registerAlias("arraylist", ArrayList.class);
        registerAlias("collection", Collection.class);
        registerAlias("iterator", Iterator.class);
    
        registerAlias("ResultSet", ResultSet.class);
      }

    四 environments的解析

    在mybatis中事务管理器和数据源是在environments中配置的,配置如下:

        <environments default="development">
            <environment id="development">
                <transactionManager type="JDBC" />
                <dataSource type="POOLED">
                    <property name="driver" value="${driver}" />
                    <property name="url" value="${url}" />
                    <property name="username" value="${username}" />
                    <property name="password" value="${password}" />
                </dataSource>
            </environment>
        </environments>

    对应的源码如下:

      private void environmentsElement(XNode context) throws Exception {
        if (context != null) {
          if (environment == null) {
    // 获取environments中default的属性值 environment
    = context.getStringAttribute("default"); } for (XNode child : context.getChildren()) {
    // 获取id的属性 String id
    = child.getStringAttribute("id");
    // 判断子节点id的属性和父节点environments的default属性是否相同,相同返回true,否则返回false
    if (isSpecifiedEnvironment(id)) {
    // 解析transactionManager节点 TransactionFactory txFactory
    = transactionManagerElement(child.evalNode("transactionManager"));
    // 解析DataSource节点 DataSourceFactory dsFactory
    = dataSourceElement(child.evalNode("dataSource"));
    // 获取DataSource对象 DataSource dataSource
    = dsFactory.getDataSource(); Environment.Builder environmentBuilder = new Environment.Builder(id) .transactionFactory(txFactory) .dataSource(dataSource);
    // 构建environment对象并设置到configuration中 configuration.setEnvironment(environmentBuilder.build()); } } } }

    现在还有一个问题值得思考:对于<property name="driver" value="${driver}" /> 中,value值是如何被赋值的?接下里,我们跟踪源码进行分析:

    进入XMLConfigBuilder类的environmentsElement方法,有这么一行代码: DataSourceFactory dsFactory = dataSourceElement(child.evalNode("dataSource"));是解析DataSource节点的,上述的过程就是在这个方法中发生的,进入该方法:

      private DataSourceFactory dataSourceElement(XNode context) throws Exception {
        if (context != null) {
    // 获取DataSource的type属性 String type
    = context.getStringAttribute("type");
    // 解析DataSource所有的子节点信息并封装为属性对象Properties,进入该方法 Properties props
    = context.getChildrenAsProperties(); DataSourceFactory factory = (DataSourceFactory) resolveClass(type).newInstance(); factory.setProperties(props); return factory; } throw new BuilderException("Environment declaration requires a DataSourceFactory."); }

    进入context.getChildrenAsProperties();方法:

      public Properties getChildrenAsProperties() {
        Properties properties = new Properties();
    //进入getChildren()方法
    for (XNode child : getChildren()) { String name = child.getStringAttribute("name"); String value = child.getStringAttribute("value"); if (name != null && value != null) { properties.setProperty(name, value); } } return properties; } public List<XNode> getChildren() { List<XNode> children = new ArrayList<XNode>(); NodeList nodeList = node.getChildNodes(); if (nodeList != null) { for (int i = 0, n = nodeList.getLength(); i < n; i++) { Node node = nodeList.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) {
    // 对DataSource中所有的子节点构建成XNode对象,并放到children集合中,进入这个构造方法 children.add(
    new XNode(xpathParser, node, variables)); } } } return children; }
    // 这个是DataSource的子节点的构造方法,具体的说就是把 <property name="driver" value="${driver}" /> 构造成一个XNode节点
    public XNode(XPathParser xpathParser, Node node, Properties variables) { this.xpathParser = xpathParser; this.node = node; this.name = node.getNodeName(); this.variables = variables;
    //value赋值过程在这个方法中
    this.attributes = parseAttributes(node); this.body = parseBody(node); }
      private Properties parseAttributes(Node n) {
        Properties attributes = new Properties();
    // 获取子节点的所有属性 如:[name="driver", value="${driver}"] NamedNodeMap attributeNodes
    = n.getAttributes(); if (attributeNodes != null) { for (int i = 0; i < attributeNodes.getLength(); i++) { Node attribute = attributeNodes.item(i);
    // 对${dirver}的解析赋值在parse方法中 String value
    = PropertyParser.parse(attribute.getNodeValue(), variables); attributes.put(attribute.getNodeName(), value); } } return attributes; }
    // 根据varibles中已经存储的值,对给定的${driver},在varibles中找到driver对应的真实值,并进行替换
    public static String parse(String string, Properties variables) { VariableTokenHandler handler = new VariableTokenHandler(variables); GenericTokenParser parser = new GenericTokenParser("${", "}", handler); return parser.parse(string); }

     五 TypeHandler的解析

    当向数据库中存储或者取出数据时,需要将数据库中的字段类型和java类型做一个转换,比如从数据库中取出的CHAR类型,转换为java中的String类型,这个功能就委托给类型处理器TypeHandler来处理。

    mybatis已经提供了一些常见的类型处理器,如StringTypeHandler,ArrayTypeHandler,LongTypeHandler等,能够满足大多数的开发需求。对于某些特殊的需求,我们也可以自定义类型处理器,需要继承

    BaseTypeHandler这个抽象类。下面我们自定义一个MyStringTypeHandler类型处理器,用于扩展StringTypeHandler的功能:

    package com.yht.mybatisTest.typeHandlers;
    
    import java.sql.CallableStatement;
    import java.sql.PreparedStatement;
    import java.sql.ResultSet;
    import java.sql.SQLException;
    
    import org.apache.ibatis.type.BaseTypeHandler;
    import org.apache.ibatis.type.JdbcType;
    
    /**
     * @author chenyk
     * @date 2018年8月22日
     */
    
    public class MyStringTypeHandler extends BaseTypeHandler<String>{
    
         @Override
          public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
              throws SQLException {
              System.out.println("新增逻辑");
              ps.setString(i, parameter);
          }
    
          @Override
          public String getNullableResult(ResultSet rs, String columnName)
              throws SQLException {
              System.out.println("新增逻辑");
              return rs.getString(columnName);
          }
    
          @Override
          public String getNullableResult(ResultSet rs, int columnIndex)
              throws SQLException {
              System.out.println("新增逻辑");
            return rs.getString(columnIndex);
          }
    
          @Override
          public String getNullableResult(CallableStatement cs, int columnIndex)
              throws SQLException {
              System.out.println("新增逻辑");
              return cs.getString(columnIndex);
          }
    
    }

    类型处理器在配置文件中有两种配置方法:

        <!-- 手动配置 -->
        <typeHandlers>
            <typeHandler jdbcType="CHAR" javaType="String" handler="com.yht.mybatisTest.typeHandlers.MyStringTypeHandler" />
        </typeHandlers>
        
        <!-- 自动扫描 -->
        <typeHandlers>
            <package name="com.yht.mybatisTest.typeHandlers"/>
        </typeHandlers>

    看源码部分:

    private void typeHandlerElement(XNode parent) throws Exception {
        if (parent != null) {
          for (XNode child : parent.getChildren()) {
            if ("package".equals(child.getName())) {
    // 扫描指定的包 然后注册TypeHandler String typeHandlerPackage
    = child.getStringAttribute("name"); typeHandlerRegistry.register(typeHandlerPackage); } else {
    // 获取javaType属性值,jdbcType属性值,handler属性值,然后注册TypeHandler String javaTypeName
    = child.getStringAttribute("javaType"); String jdbcTypeName = child.getStringAttribute("jdbcType"); String handlerTypeName = child.getStringAttribute("handler"); Class<?> javaTypeClass = resolveClass(javaTypeName); JdbcType jdbcType = resolveJdbcType(jdbcTypeName); Class<?> typeHandlerClass = resolveClass(handlerTypeName); if (javaTypeClass != null) { if (jdbcType == null) { typeHandlerRegistry.register(javaTypeClass, typeHandlerClass); } else { typeHandlerRegistry.register(javaTypeClass, jdbcType, typeHandlerClass); } } else { typeHandlerRegistry.register(typeHandlerClass); } } } } }

    根据不同的情况,注册TypeHandler共有四种方法,这里我们选一种进行分析:typeHandlerRegistry.register(javaTypeClass, jdbcType, typeHandlerClass);

      private void register(Type javaType, JdbcType jdbcType, TypeHandler<?> handler) {
        if (javaType != null) {
          Map<JdbcType, TypeHandler<?>> map = TYPE_HANDLER_MAP.get(javaType);
          if (map == null) {
            map = new HashMap<JdbcType, TypeHandler<?>>();
    // 存储JavaType到Map<JdbcType,TypeHandler>的映射 TYPE_HANDLER_MAP.put(javaType, map); } map.put(jdbcType, handler);
    if (reversePrimitiveMap.containsKey(javaType)) { register(reversePrimitiveMap.get(javaType), jdbcType, handler); } }
    //存储所有的TypeHandler ALL_TYPE_HANDLERS_MAP.put(handler.getClass(), handler); }

    mybatis内置的TypeHandler有哪些呢?贴出源码:

     private final Map<JdbcType, TypeHandler<?>> JDBC_TYPE_HANDLER_MAP = new EnumMap<JdbcType, TypeHandler<?>>(JdbcType.class);
      private final Map<Type, Map<JdbcType, TypeHandler<?>>> TYPE_HANDLER_MAP = new HashMap<Type, Map<JdbcType, TypeHandler<?>>>();
      private final TypeHandler<Object> UNKNOWN_TYPE_HANDLER = new UnknownTypeHandler(this);
      private final Map<Class<?>, TypeHandler<?>> ALL_TYPE_HANDLERS_MAP = new HashMap<Class<?>, TypeHandler<?>>();
    
      public TypeHandlerRegistry() {
        register(Boolean.class, new BooleanTypeHandler());
        register(boolean.class, new BooleanTypeHandler());
        register(JdbcType.BOOLEAN, new BooleanTypeHandler());
        register(JdbcType.BIT, new BooleanTypeHandler());
    
        register(Byte.class, new ByteTypeHandler());
        register(byte.class, new ByteTypeHandler());
        register(JdbcType.TINYINT, new ByteTypeHandler());
    
        register(Short.class, new ShortTypeHandler());
        register(short.class, new ShortTypeHandler());
        register(JdbcType.SMALLINT, new ShortTypeHandler());
    
        register(Integer.class, new IntegerTypeHandler());
        register(int.class, new IntegerTypeHandler());
        register(JdbcType.INTEGER, new IntegerTypeHandler());
    
        register(Long.class, new LongTypeHandler());
        register(long.class, new LongTypeHandler());
    
        register(Float.class, new FloatTypeHandler());
        register(float.class, new FloatTypeHandler());
        register(JdbcType.FLOAT, new FloatTypeHandler());
    
        register(Double.class, new DoubleTypeHandler());
        register(double.class, new DoubleTypeHandler());
        register(JdbcType.DOUBLE, new DoubleTypeHandler());
    
        register(String.class, new StringTypeHandler());
        register(String.class, JdbcType.CHAR, new StringTypeHandler());
        register(String.class, JdbcType.CLOB, new ClobTypeHandler());
        register(String.class, JdbcType.VARCHAR, new StringTypeHandler());
        register(String.class, JdbcType.LONGVARCHAR, new ClobTypeHandler());
        register(String.class, JdbcType.NVARCHAR, new NStringTypeHandler());
        register(String.class, JdbcType.NCHAR, new NStringTypeHandler());
        register(String.class, JdbcType.NCLOB, new NClobTypeHandler());
        register(JdbcType.CHAR, new StringTypeHandler());
        register(JdbcType.VARCHAR, new StringTypeHandler());
        register(JdbcType.CLOB, new ClobTypeHandler());
        register(JdbcType.LONGVARCHAR, new ClobTypeHandler());
        register(JdbcType.NVARCHAR, new NStringTypeHandler());
        register(JdbcType.NCHAR, new NStringTypeHandler());
        register(JdbcType.NCLOB, new NClobTypeHandler());
    
        register(Object.class, JdbcType.ARRAY, new ArrayTypeHandler());
        register(JdbcType.ARRAY, new ArrayTypeHandler());
    
        register(BigInteger.class, new BigIntegerTypeHandler());
        register(JdbcType.BIGINT, new LongTypeHandler());
    
        register(BigDecimal.class, new BigDecimalTypeHandler());
        register(JdbcType.REAL, new BigDecimalTypeHandler());
        register(JdbcType.DECIMAL, new BigDecimalTypeHandler());
        register(JdbcType.NUMERIC, new BigDecimalTypeHandler());
    
        register(Byte[].class, new ByteObjectArrayTypeHandler());
        register(Byte[].class, JdbcType.BLOB, new BlobByteObjectArrayTypeHandler());
        register(Byte[].class, JdbcType.LONGVARBINARY, new BlobByteObjectArrayTypeHandler());
        register(byte[].class, new ByteArrayTypeHandler());
        register(byte[].class, JdbcType.BLOB, new BlobTypeHandler());
        register(byte[].class, JdbcType.LONGVARBINARY, new BlobTypeHandler());
        register(JdbcType.LONGVARBINARY, new BlobTypeHandler());
        register(JdbcType.BLOB, new BlobTypeHandler());
    
        register(Object.class, UNKNOWN_TYPE_HANDLER);
        register(Object.class, JdbcType.OTHER, UNKNOWN_TYPE_HANDLER);
        register(JdbcType.OTHER, UNKNOWN_TYPE_HANDLER);
    
        register(Date.class, new DateTypeHandler());
        register(Date.class, JdbcType.DATE, new DateOnlyTypeHandler());
        register(Date.class, JdbcType.TIME, new TimeOnlyTypeHandler());
        register(JdbcType.TIMESTAMP, new DateTypeHandler());
        register(JdbcType.DATE, new DateOnlyTypeHandler());
        register(JdbcType.TIME, new TimeOnlyTypeHandler());
    
        register(java.sql.Date.class, new SqlDateTypeHandler());
        register(java.sql.Time.class, new SqlTimeTypeHandler());
        register(java.sql.Timestamp.class, new SqlTimestampTypeHandler());
    
        // issue #273
        register(Character.class, new CharacterTypeHandler());
        register(char.class, new CharacterTypeHandler());
      }

    到现在为止,对于配置文件中常见节点的解析过程做了分析,接下来在下一篇文章中我们继续对映射文件的解析过程进行讲解。

  • 相关阅读:
    15.scrapy中selenium的应用
    14.UA池和代理池
    17.基于scrapy-redis两种形式的分布式爬虫
    08.Python网络爬虫之图片懒加载技术、selenium和PhantomJS
    05.Python网络爬虫之三种数据解析方式
    06.Python网络爬虫之requests模块(2)
    04.Python网络爬虫之requests模块(1)
    16.Python网络爬虫之Scrapy框架(CrawlSpider)
    10.scrapy框架简介和基础应用
    Python网络爬虫第三弹《爬取get请求的页面数据》
  • 原文地址:https://www.cnblogs.com/51life/p/9512225.html
Copyright © 2011-2022 走看看