《深入理解mybatis原理》MyBatis的架构设计以及实例分析转载
原创MyBatis它目前很受欢迎。ORM框架,它非常强大,但它的实现相对简单和优雅。本文的重点是MyBatis建筑设计理念,并讨论MyBatis几个核心组件,然后将一个select查询实例,钻取代码,浏览。MyBatis的实现。
一、MyBatis框架设计
注:以上数字为大体上参考。iteye 上的chenjc_it 博客文章写作的第二个原则分析:框架的总体设计 中的MyBatis建筑主体图,chenjc_it总结得很好,表扬一声!
1.接口层---以及数据库交互的方式
MyBatis与数据库交互的方式有两种:
a.使用传统的MyBatis提供的API;
b. 使用Mapper接口
1.1.使用传统的MyBatis提供的API
这是传统的传输方式Statement Id 和查询参数 SqlSession 对象,使用 SqlSession对象完成和数据库交互;MyBatis 提供了一种非常方便和简单的API实现了对数据库中数据的添加和删除操作,以及数据库的连接信息和。MyBatis 自我配置信息的维护操作。
上述使用MyBatis 方法,就是创建一个数据库来处理。SqlSession对象,然后根据Statement Id 和参数一起操作数据库,这种方式很简单实用,但它不符合面向对象语言的概念和面向接口的编程习惯。由于面向接口编程是面向对象的大趋势,MyBatis 为了适应这一趋势,增加了第二种用途MyBatis 支持界面(Interface)调用方法。
1.2. 使用Mapper接口
MyBatis 每个配置文件都将是
根据MyBatis 在完成配置规范后SqlSession.getMapper(XXXMapper.class) 方法,MyBatis 基于由相应接口声明的方法信息来生成动态代理机制。Mapper 实例,我们使用Mapper 当接口的方法时,MyBatis 将根据此方法的方法名称和参数类型确定。Statement Id、底层或通透SqlSession.select("statementId",parameterObject);或者SqlSession.update("statementId",parameterObject); 等实现对数据库的操作,(关于这里的动态机制是如何实现的,我会准备一篇专门的文章来讨论,请注意~)
MyBatis 引用Mapper 调用该接口纯粹是为了满足面向接口编程的需要。(事实上,另一个原因是面向接口的编程允许用户在接口上使用注释来配置SQL语句,这样您就可以脱离XML配置文件,实施“0配置“)。
2.数据处理层
数据处理层可以说是MyBatis 处于……的核心
a. 通过传入参数来构建动态。SQL语句;
b. SQL语句执行和封装查询结果的集成。List
2.1.参数映射和动态SQL语句生成
动态语句生成可以说是MyBatis框住了一个非常优雅的设计,MyBatis 通过传入参数值,使用。 Ognl 动态构建SQL语句,以便MyBatis 具有较强的灵活性和可扩展性。
参数映射指的是java 数据类型和jdbc数据类型之间的转换:有两个过程:查询阶段,我们会。java数据类型,已转换jdbc数据类型, preparedStatement.setXXX() 设定值;resultset查询结果集jdbcType 数据转换java 数据类型。
(至于具体情况MyBatis是如何动态构建SQL我将准备一篇专题文章来讨论这一声明,请注意。~)
2.2. SQL语句执行和封装查询结果的集成。List
动态SQL在生成该语句之后,MyBatis 将执行SQL语句,并将可能返回的结果集转换为List
- 框架支撑层
3.1. 交易管理机制
交易管理机制对于ORM框架而言是不可缺少的一部分,交易管理机制的质量也是考量一个ORM一个框架是否优秀的标准,对于数据管理机制,我在我的博客文章《深入理解》中有过。mybatis原理》 MyBatis交易管理机制 有一个非常详细的讨论,感兴趣的读者可以点击查看。
3.2. 连接池管理机制
因为创建数据库连接会占用大量资源, 对于数据吞吐量大、流量非常大的应用,连接池的设计是非常重要的。我已经在我的博客文章《深入理解》中了解了连接池管理机制。mybatis原理》 Mybatis数据源和连接池 有一个非常详细的讨论,感兴趣的读者可以点击查看。
3.3. 缓存机制
为了提高数据利用率并减轻服务器和数据库的压力,MyBatis 为某些查询提供会话级数据缓存,并将查询放在SqlSession 在中,对于允许的间隔内的完全相同的查询,MyBatis 会直接将缓存结果返回给用户,而不用再到数据库中查找。(至于具体情况MyBatis缓存机制,我会准备一篇专题文章来讨论,请大家注意~)
-
- SQL语句的配置方式
传统的MyBatis 配置SQL 语句方法是使用XML文件是配置的,但这种方法不能很好地支持面向接口编程的概念,为了支持面向接口编程,MyBatis 引入了Mapper接口的概念,引入面向接口,使用注释进行配置SQL 声明是可能的。用户只需要在界面上添加必要的注释,而不需要进行配置。XML文件,但当前的MyBatis 只需配置注释即可SQL 语句提供的支持有限,一些高级功能仍依赖于XML配置文件配置SQL 语句。
4 引导层
引导层已配置并启动。MyBatis 信息是如何配置的。MyBatis 引导的两种方式MyBatis :基于XML简档的方式和基于的。Java API 读者可以参考我的另一篇博客文章:Java Persistence with MyBatis 3(中文版) 第二章 引导MyBatis
二、MyBatis的主要组件
从MyBatis从代码实现的角度来看,MyBatis主要核心组件如下:
SqlSession 作为MyBatis工作的主要顶层API表示与数据库交互并完成必要的数据库添加、删除和修改功能的会话
Executor MyBatis执行机构,ISMyBatis 调度核心,有责任感SQL语句生成和查询缓存维护。
StatementHandler 封装了JDBC Statement运营,负责JDBC statement 操作,如设置参数,将。Statement转换结果集List集合。
ParameterHandler 负责传递给用户的参数被转换JDBC Statement 必填参数,
ResultSetHandler 负责将JDBC返回的ResultSet转换结果集对象。List类型集合;
TypeHandler 负责java数据类型和jdbc数据类型之间的映射和转换
MappedStatement MappedStatement保持为<select|update|delete|insert>节点的封装,
SqlSource 负责用户的交付。parameterObject,动态生成SQL语句,封装信息BoundSql对象,并返回
BoundSql 表示动态生成的SQL语句和相应的参数信息
Configuration MyBatis维护所有配置信息Configuration物体。
(注:这只是我个人认为属于核心的组件的列表。请不要有先入为主的想法MyBatis只有这几个部分!每个人都是对的。MyBatis理解不同,分析的结果自然也会不同,欢迎读者提出问题和不同的意见,我们一起探讨。~)
它们之间的关系如下图所示:
三、从MyBatis一次select 要分析的查询语句MyBatis建筑设计
1.数据准备(非常熟悉和实用MyBatis 读者可以快速浏览这一部分)
- 准备数据库数据,创建EMPLOYEES表格,插入数据:
--创建员工基本信息表。
create table "EMPLOYEES"(
"EMPLOYEE_ID" NUMBER(6) not null,
"FIRST_NAME" VARCHAR2(20),
"LAST_NAME" VARCHAR2(25) not null,
"EMAIL" VARCHAR2(25) not null unique,
"SALARY" NUMBER(8,2),
constraint "EMP_EMP_ID_PK" primary key ("EMPLOYEE_ID")
);
comment on table EMPLOYEES is 员工信息表;
comment on column EMPLOYEES.EMPLOYEE_ID is 员工id;
comment on column EMPLOYEES.FIRST_NAME is first name;
comment on column EMPLOYEES.LAST_NAME is last name;
comment on column EMPLOYEES.EMAIL is email address;
comment on column EMPLOYEES.SALARY is salary;
--添加数据
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (100, Steven, King, SKING, 24000.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (101, Neena, Kochhar, NKOCHHAR, 17000.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (102, Lex, De Haan, LDEHAAN, 17000.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (103, Alexander, Hunold, AHUNOLD, 9000.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (104, Bruce, Ernst, BERNST, 6000.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (105, David, Austin, DAUSTIN, 4800.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (106, Valli, Pataballa, VPATABAL, 4800.00);
insert into EMPLOYEES (EMPLOYEE_ID, FIRST_NAME, LAST_NAME, EMAIL, SALARY)
values (107, Diana, Lorentz, DLORENTZ, 4200.00);
- 配置Mybatis的配置文件,名为mybatisConfig.xml:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
3. 创建Employee实体Bean 以及配置Mapper配置文件
package com.louis.mybatis.model;
import java.math.BigDecimal;
public class Employee {
private Integer employeeId;
private String firstName;
private String lastName;
private String email;
private BigDecimal salary;
public Integer getEmployeeId() {
return employeeId;
}
public void setEmployeeId(Integer employeeId) {
this.employeeId = employeeId;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public BigDecimal getSalary() {
return salary;
}
public void setSalary(BigDecimal salary) {
this.salary = salary;
}
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
- 创建eclipse 或者myeclipse 的maven项目,maven配置如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
- 客户端代码:
package com.louis.mybatis.test;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import com.louis.mybatis.model.Employee;
/**
- SqlSession 简单查询演示类
- @author louluan
*/
public class SelectDemo {
public static void main(String[] args) throws Exception {
/*
- 1.加载mybatis配置文件的初始化mybatis,创建出SqlSessionFactory,是创建SqlSession的工厂
- 这仅用于演示目的,SqlSessionFactory临时创建,并在实际使用中,SqlSessionFactory您只需创建一次并将其用作单个案例。
*/
InputStream inputStream = Resources.getResourceAsStream("mybatisConfig.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(inputStream);
//2. 从SqlSession工厂 SqlSessionFactory创建SqlSession、数据库操作
SqlSession sqlSession = factory.openSession();
//3.使用SqlSession查询
Map<String,Object> params = new HashMap<String,Object>();
params.put("min_salary",10000);
//a.询价工资较低10000的员工
List
//b.没有超过最低工资标准,请检查所有员工
List
System.out.println("薪资低于10000员工的比例:"+result.size());
//~output : 查询数据总数:5
System.out.println("所有员工人数: "+result1.size());
//~output : 所有员工人数: 8
}
}
二、SqlSession 工作流程分析:
-
打开数据库访问会话---创建SqlSession对象:
SqlSession sqlSession = factory.openSession();
MyBatis封装对数据库的访问,并对数据库进行会话和事务控制。SqlSession对象中。 -
为SqlSession传递已配置的Sql语句 的Statement Id和参数,然后返回结果:
Listresult = sqlSession.selectList("com.louis.mybatis.dao.EmployeesMapper.selectByMinSalary",params);
上述的"com.louis.mybatis.dao.EmployeesMapper.selectByMinSalary",配置在EmployeesMapper.xml 的Statement ID,params 是传递的查询参数。
让我们来看看sqlSession.selectList()方法定义:
public
return this.selectList(statement, parameter, RowBounds.DEFAULT);
}
public
try {
//1.根据Statement Id,在mybatis 配置对象Configuration在以下位置找到相应的配置文件MappedStatement
MappedStatement ms = configuration.getMappedStatement(statement);
//2. 委托查询任务MyBatis 的执行器 Executor
List
return result;
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
MyBatis在初始化时,MyBatis配置信息全部加载到内存中,使用org.apache.ibatis.session.Configuration要维护的实例。用户可以使用sqlSession.getConfiguration()方法获取。MyBatis配置文件中配置信息的组织格式与内存中对象的组织格式几乎完全一致。在上面的例子中
加载到内存中会生成对应的MappedStatement对象,该对象后跟一个key="com.louis.mybatis.dao.EmployeesMapper.selectByMinSalary" ,value为MappedStatement对象的形状保持不变。Configuration的一个Map在……里面。当它需要在未来使用时,只能通过Id获得它的价值。
从上面的代码中我们可以看到SqlSession功能:
SqlSession根据Statement ID, 在mybatis配置对象Configuration相应的MappedStatement对象,然后调用mybatis执行特定操作的执行器。
3.MyBatis执行器Executor根据SqlSession将执行传递的参数。query()方法(因为代码太长,读者只需要阅读我注释的地方):
/**
- BaseExecutor 类部分代码
-
*/
publicList query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
// 1.根据具体传入的参数,动态生成需要执行的SQL语句,用BoundSql对象表示
BoundSql boundSql = ms.getBoundSql(parameter);
// 2.为当前查询创建缓存Key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
public
ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
if (closed) throw new ExecutorException("Executor was closed.");
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List
try {
queryStack++;
list = resultHandler == null ? (List
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
// 3.缓存中没有值,数据直接从数据库中读取。
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}
deferredLoads.clear(); // issue #601
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
clearLocalCache(); // issue #482
}
}
return list;
}
private
List
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
//4. 执行查询并返回List 结果,然后 将查询结果放入缓存。
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
/**
-
*SimpleExecutor类的doQuery()方法实现
-
*/
publicList doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
//5. 根据已有参数,创建StatementHandler对象来执行查询操作。
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
//6. 创建java.Sql.Statement对象,传递StatementHandler对象
stmt = prepareStatement(handler, ms.getStatementLog());
//7. 调用StatementHandler.query()方法,返回List结果集
return handler.query(stmt, resultHandler);
} finally {
closeStatement(stmt);
}
}
上述的Executor.query()该方法反复执行几次,最终创建了一个StatementHandler对象,然后将必要的参数传递给StatementHandler,使用StatementHandler完成对数据库的查询并最终返回List结果集。
正如我们从上面的代码中看到的,Executor以下机构的职能和职能:
(1,根据传递的参数,完成。SQL动态解析语句,生成BoundSql对象,供StatementHandler使用;
(2,为查询创建缓存以提高性能(具体的缓存机制不是本文的重点,我将单独拿出来与您讨论,感兴趣的读者可以关注我的其他博客帖子);
(3、创建JDBC的Statement连接对象,传递StatementHandler对象,返回List查询结果。
- StatementHandler对象负责设置Statement查询对象中的参数,处理JDBC返回的resultSet,将resultSet加工为List 集合退货:
然后是上面的Executor第六步,看一看:prepareStatement() 该该该方法的实现:
/**
-
*SimpleExecutor类的doQuery()方法实现
-
*/
publicList doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { Statement stmt = null; try { Configuration configuration = ms.getConfiguration(); StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql); // 1.准备Statement对象,并设置Statement对象的参数 stmt = prepareStatement(handler, ms.getStatementLog()); // 2. StatementHandler执行query()方法,返回List结果 return handler. query(stmt, resultHandler); } finally { closeStatement(stmt); } }
private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
Statement stmt;
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection);
//对创建的Statement对象设置参数,即设置SQL 语句中 ? 设置为指定的参数
handler.parameterize(stmt);
return stmt;
}
以上我们可以总结一下StatementHandler该对象主要完成两个任务:
(1. 对于JDBC的PreparedStatement对象的类型,创建过程,我们使用SQL语句字符串将包含 若干个? 占位符,然后我们设置占位符的值。
StatementHandler通过parameterize(statement)方法对Statement设置值;
(2.StatementHandler通过List
5. StatementHandler 的parameterize(statement) 该该该方法的实现:
/**
- StatementHandler 类的parameterize(statement) 方法实现
*/
public void parameterize(Statement statement) throws SQLException {
//使用ParameterHandler对象来完成配对。Statement的设值
parameterHandler.setParameters((PreparedStatement) statement);
}
/**
-
*ParameterHandler类的setParameters(PreparedStatement ps) 实现
- 对某一个Statement设置参数
*/
public void setParameters(PreparedStatement ps) throws SQLException {
ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
ListparameterMappings = boundSql.getParameterMappings();
if (parameterMappings != null) {
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
// 每一个Mapping都有一个TypeHandler,根据TypeHandler来对preparedStatement设置参数
TypeHandler typeHandler = parameterMapping.getTypeHandler();
JdbcType jdbcType = parameterMapping.getJdbcType();
if (value == null && jdbcType == null) jdbcType = configuration.getJdbcTypeForNull();
// 设置参数
typeHandler.setParameter(ps, i + 1, value, jdbcType);
}
}
}
}
正如您从上面的代码中看到的那样,StatementHandler 的parameterize(Statement) 该方法调用 ParameterHandler的setParameters(statement) 方法,
ParameterHandler的setParameters(Statement)方法负责 根据我们输入的参数,这对statement对象的 ? 该值在占位符处赋值。
6. StatementHandler 的List
/**
- PreParedStatement类的query方法实现
*/
publicList query(Statement statement, ResultHandler resultHandler) throws SQLException {
// 1.调用preparedStatemnt。execute()方法,然后resultSet交给ResultSetHandler处理
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
//2. 使用ResultHandler来处理ResultSet
return resultSetHandler.handleResultSets(ps);
}
/*
ResultSetHandler类的handleResultSets()方法实现 -
*/
public List
int resultSetCount = 0;
ResultSetWrapper rsw = getFirstResultSet(stmt);
List
int resultMapCount = resultMaps.size();
validateResultMapsCount(rsw, resultMapCount);
while (rsw != null && resultMapCount > resultSetCount) {
ResultMap resultMap = resultMaps.get(resultSetCount);
//将resultSet
handleResultSet(rsw, resultMap, multipleResults, null);
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
String[] resultSets = mappedStatement.getResulSets();
if (resultSets != null) {
while (rsw != null && resultSetCount < resultSets.length) {
ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
if (parentMapping != null) {
String nestedResultMapId = parentMapping.getNestedResultMapId();
ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
handleResultSet(rsw, resultMap, null, parentMapping);
}
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
}
return collapseSingleResultList(multipleResults);
}
从上面的代码中我们可以看到,StatementHandler 的List
//
// DefaultResultSetHandler 类的handleResultSets(Statement stmt)实现
//HANDLE RESULT SETS
//
public List
final List
int resultSetCount = 0;
ResultSetWrapper rsw = getFirstResultSet(stmt);
List
int resultMapCount = resultMaps.size();
validateResultMapsCount(rsw, resultMapCount);
while (rsw != null && resultMapCount > resultSetCount) {
ResultMap resultMap = resultMaps.get(resultSetCount);
//将resultSet
handleResultSet(rsw, resultMap, multipleResults, null);
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
String[] resultSets = mappedStatement.getResulSets();
if (resultSets != null) {
while (rsw != null && resultSetCount < resultSets.length) {
ResultMapping parentMapping = nextResultMaps.get(resultSets[resultSetCount]);
if (parentMapping != null) {
String nestedResultMapId = parentMapping.getNestedResultMapId();
ResultMap resultMap = configuration.getResultMap(nestedResultMapId);
handleResultSet(rsw, resultMap, null, parentMapping);
}
rsw = getNextResultSet(stmt);
cleanUpAfterHandlingResultSet();
resultSetCount++;
}
}
return collapseSingleResultList(multipleResults);
}
由于上述工艺时序图过于复杂,暂不张贴,读者可下载。MyBatis源码, 使用Eclipse、Intellij IDEA、NetBeans 等IDE集成环境来创建项目,Debug MyBatis源代码,逐步跟踪MyBatis等学习的实现。MyBatis框架是有帮助的~
作者的话
这篇文章是《深入理解》。mybatis其中一个原则系列,如果你感兴趣,请关注该系列的其他文章~
我觉得这篇文章很好,请给我一个称赞~~您的鼓励是我继续分享知识的强大动力!
————————————————
版权声明:本文是CSDN博客作者一山的原文如下 CC 4.0 BY-SA 版权协议,转载请附上原始来源链接和本声明。
原始链接:https://blog.csdn.net/luanlouis/article/details/40422941
版权声明
所有资源都来源于爬虫采集,如有侵权请联系我们,我们将立即删除
itfan123


