标签:pointer 字符 out 持久层 int 起点 容器 循环 struct
//把创建的对象都放到applicationContext容器中,要用时,在通过id一个一个取出来
主配置文件
<beans>
<bean id="a" class="com.dh.service.impl.SomeServiceImpl"/>
</beans>
//在spring中,每一个java对象对应一个<bean>标签,即一个<bean>标签代表一个java对象
//id:对象的自定义名称,唯一值,spring通过这个名称找到该对象
//class:类的全限定名称(不能是接口,spring通过反射机制创建对象)
//spring把创建好的对象放到map集合中,集合的key就是上面的id值,value是创建的对象
//spring创建对象默认调用无参构造方法
测试程序
String config="beans.xml";
ApplicationContext ac=new ClassPathXmlApplicationContext(config); (2)
?
Object object=ac.getBean("a"); //此时对象已经创建好了,通过id,从容器中获取相对应的对象
SomeService service=(SomeService)object;
service.doSome();
//只要是配置文件,都是从target/classes的根目录为起点开始找的
//ApplicationContext就是spring的容器,在创建spring容器时,会创建配置文件中的所有java对象,也就是在(2)步时,先创建容器,再创建java对象
//ClassPathXmlApplicationContext(config):表示从类路径(即target/classes目录)中加载spring的配置文件; FileSystemXmlApplicationContext():表示从本地磁盘中加载spring的配置文件,不常用
获取容器中定义的对象数量
int nums=ac.getBeanDefinitionCount();
System.out.println(nums);
获取容器中定义的对象名称
String[] names=ac.getBeanDefinitionNames();
for (String n:names){
System.out.println(n); //a
}
//在spring的配置文件中,使用标签和属性完成赋值,叫做基于XML的DI实现
//set方法的执行是在构造方法之后执行的
简单注入
<bean id="mystudent" class="com.dh.bao.Student">
<property name="name" value="zs"/>
<property name="age" value="25"/>
</bean>
//name="属性名字",value="赋给属性的值"
//com.dh.bao.Student类中所赋值的属性必须有set()方法,没有就报错;有set(),但里面没有this.age = age;赋值语句,不报错,只是该属性不能被赋值,为null
//spring只关心有没有对应的set()方法,即使只有set()方法,没有属性,系统也会正常运行
//若在set()方法中有System.out.println(123);语句,该语句也会正常运行,输出123
引用类型注入
<bean id="mystudent" class="com.dh.bao.Student">
<property name="name" value="zs"/>
<property name="age" value="25"/>
</bean>
?
<bean id="myschool" class="com.dh.bao.School">
<property name="name" value="东华大学"/>
<property name="address" value="上海"/>
<property name="student" ref="mystudent"/>
</bean>
//com.dh.bao.School类中有Student属性,使用ref属性完成赋值
//两个<bean>标签的位置没有规定,谁在上方都可以。因为在第一次因位置没加载到时,会进行二次扫描
//通过有参构造方法赋值
<bean id="gouzao" class="com.dh.bao.School">
<constructor-arg name="name" value="gzdx"/>
<constructor-arg name="address" value="gz"/>
<constructor-arg name="student" ref="my2"/>
</bean>
<bean id="my2" class="com.dh.bao.Student">
<constructor-arg name="name" value="ww"/>
<constructor-arg name="age" value="66"/>
</bean>
byName
<bean id="student" class="com.dh.bao.Student">
<property name="name" value="ls"/>
<property name="age" value="77"/>
</bean>
?
<bean id="byName" class="com.dh.bao.School" autowire="byName">
<property name="name" value="东大"/>
<property name="address" value="上海"/>
</bean>
//student的<bean>中的id值必须和com.dh.bao.School类中的Student类型的引用名称相同
//在School的<bean>中添加autowire="byName"属性
byType
//同源关系
java类中引用属性的数据类型和该引用属性相关的<bean>中的class是一样的
java类中引用属性的数据类型和该引用属性相关的<bean>中的class是父子关系
java类中引用属性的数据类型和该引用属性相关的<bean>中的class是接口与实现类关系
<bean id="byType" class="com.dh.bao.School" autowire="byType">
<property name="name" value="东大"/>
<property name="address" value="上海"/>
</bean>
<bean id="student" class="com.dh.bao.Student">
<property name="name" value="ls"/>
<property name="age" value="77"/>
</bean>
//在spring的配置文件中也是由上而下逐行执行
//当执行到School的<bean>标签时,先创建school对象,然后再给其简单属性赋值。最后执行autowire="byType"语句;扫描整个spring配置文件,找与School对象的属性student同源的,即student的子类,student类本身,若student是接口,其实现类也行。找到之后赋值即可
//注意:在使用byType时,同时只能有一个符合条件,当有多个<bean>标签符合条件时,系统报错
Component注解
//当只有value一个属性时,value可以省略不写;也可以不指定对象的名称,Spring默认提供类名首字母小写为对象名称
配置文件中设置组件扫描器
<beans>
<context:component-scan base-package="com.dh.bao"/>
</beans>
//spring会扫描base-package属性指定的包和其子包中所有的类,找到类中的注解,按照注解的功能创建对象或者赋值
@Repository:用在持久层上,放在dao的实现类上,创建dao对象
@Service:用在业务层上,创建service对象,可以有事务等功能
@Controller:用在界面层上,创建控制器对象
//以上3个注解的使用语法与@Component一样,都能创建对象,但这3个注解还有其他的额外功能
//当某个类不属于以上3种情况时,才用@Component创建对象
//属性value是String类型
//@Value注解可以出现在属性的定义上(推荐),也可以出现在set()方法上
//@Value注解是通过反射机制赋值的,所以该类可以没有set()方法
@Component(value="mySchool")
public class School {
@Value("donghua")
private String name;
@Value("shanghai")
private String address;
@Autowired
private Student student;
...
}
?
@Component(value="myStudent")
public class Student {
@Value(value = "zs")
private String name;
@Value(value="29")
private int age;
...
}
//@Autowired注解默认采用的是byType的方式自动注入
//只要通过组件扫描器扫描的包中有student属性对应的同源的关系的类就能完成该引用属性的赋值;或者spring配置文件有通过标签完成对该引用同源关系的类赋值的,也是可以的。即两种方式可以混用,即xml与注解
//@Autowired注解可以出现在属性的定义上(推荐),也可以出现在set()方法上。其是通过反射机制赋值,所以该类可以没有set()方法
//使用@Qualifier注解指定赋值的类的id值
//required = true:表示引用类型赋值失败时,程序报错,并终止接下来的程序 (默认)
//required = false:表示引用类型赋值失败时,程序正常执行,引用属性的值为null
//@Resource注解来自jdk中,但spring框架支持该注解的功能,用法与@Autowired注解相似
//@Resource注解默认采用byName的方式,其先使用byName的方式进行赋值,若赋值失败,则采用byType的方式再次尝试
//若只想使用byName的方式,则需添加属性name:@Resource(name="aa")
//@Resource注解可以出现在属性的定义上(推荐),也可以出现在set()方法上。其是通过反射机制赋值,所以该类可以没有set()方法
//在resources/bao目录下有spring-1,spring-2,spring-3,3个配置文件
//在spring-1中添加
<beans>
<import resource="classpath:bao/spring-2.xml"/>
<import resource="target/classes:bao/spring-3.xml"/> //classpath与target/classes是等价的
</beans>
//添加以上内容之后,spring-1就是主配置文件了,在测试程序中,只用加载spring-1,其他两个配置文件就会自动加载
//可以使用通配符“ * ”
<import resource="classpath:bao/spring-*.xml"/>
//这样就可以一次将spring-2,spring-3都导入。但要注意,在通配符表示的范围中不能包含主配置文件spring-1,不然会陷入死循环,一直加载。可以通过给主配置文件改名或者修改目录,不在同一目录下
//aop (Aspect Orient Programming) :面向切面编程。切面:给目标类增加的功能,就是切面
//aop的底层是基于动态代理的,它的的本质就是动态代理的规范化,是一个标准
//spring内部实现了aop规范,但其主要用于事务的处理。我们主要用一个专门做aop的框架:aspectJ
//所以aop支持jdk代理和cglib代理
execution (访问权限 方法的返回类型 方法的声明(参数) 异常)
//方法的返回类型,方法的声明(参数)这两项是必须要写的,其他两项可以省略不写
//每一部分之间用空格隔开
//切入表达式中可以用以下符号
*:0至多个字符
..:用在方法参数中,表示任意多个参数;用在包名后,表示当前包及其子包的路径
+:用在类名后,表示当前类及其子类;用在接口后,表示当前接口及其实现类
//例子:excution(* set*(..)) :表示以set三个字母开头的任意方法
创建接口及目标类
package com.dh.bao;
?
public interface SomeService {
void doSome(String name, Integer age);
}
?
public class SomeServiceImpl implements SomeService{ //目标类
public void doSome(String name, Integer age) {
System.out.println("abc");
}
}
创建切面类,切面表达式指向目标类
@Aspect
public class MyAspect {
@Before(value = "execution(public void com.dh.bao.SomeServiceImpl.doSome(String,Integer))")
public void MyBefore(){
System.out.println("切面:" + new Date());
}
}
声明目标及切面对象,还有声明自动代理生成器
<beans>
<bean id="someservice" class="com.dh.bao.SomeServiceImpl"/>
<bean id="myaspect" class="com.dh.bao.MyAspect"/>
?
<aop:aspectj-autoproxy/> //自动代理生成器
</beans>
//程序执行到<aop:aspectj-autoproxy/> 时,会扫描整个spring的配置文件中所有的类及对象,找到@Aspect及相关注解,将切面类指向的目标类转化为代理类
测试程序
String confg="applicationContext.xml";
ApplicationContext ac=new ClassPathXmlApplicationContext(confg);
SomeService ss=(SomeService)ac.getBean("someservice"); //这里强转的是接口,而不是接口的实现类SomeServiceImpl
s.doSome("zs",20);
?
//切面:Thu Jun 24 10:59:54 CST 2021
abc
//注意:此时ss对象的类型不是SomeService类,而是com.sun.proxy.$Proxy6类型
//若切面表达式写错了,程序依然会正常运行,不会报错;只是ss对象就不是代理类型,而是SomeService类型。原因是切面表达式不对,导致指定目标类错误,找不到目标类,也就无法完成转换
//作用是:表明当前类为切面类
//位置:定义在类上
//定义和管理切入点,若项目中有多个切入点表达式是重复的,则可以使用该注解,达到复用的目的
属性:value值(切入点表达式)
位置:在方法上
特点:当使用@Pointcut注解定义在一个方法上时,这个方法的名称就是切入表达式的别名。在其他的通知注解中,value属性的值就可以使用这个别名,代替切入点表达式
//注意:在其他通知注解中,value="www()",而不是value="www"。是要包含括号的
//切面类相对于目标类执行的时间,在aop规范中称为Advice,也就是通知。
//指定通知方法中的参数,即切面方法中的参数;所有通知方法都可以有这个参数
//通知方法:被通知注解(@Before,@After,@AfterReturning等)修饰的方法
public class MyAspect {
//目标方法的签名:void com.dh.bao.SomeServiceImpl.doSome(String,Integer)
属性:value值(切入点表达式)
位置:在方法上
特点:在目标类的方法之前执行;不改变目标方法的执行结果;不影响目标方法的执行
对切面方法的要求:要是public;要是void;方法名称自定义;方法若有参数,不能自定义,从几个参数类型中选择
属性:value值(切入点表达式);
returning:自定义变量,表示目标方法的返回值(自定义的变量名必须和通知(切面)方法的形参名一样)
位置:在方法上
特点:在目标类的方法之后执行;
能够获取到目标方法的返回值,可以在切面方法中处理这个返回值
不能改变目标方法的返回值在最后的输出
对切面方法的要求:要是public;要是void;方法名称自定义;方法有参数,推荐Object类型
public class AfterImpl implements After{
public Integer doSome() {
return 100;
}
}
?
public class AfterAspect {
属性:value值(切入点表达式)
位置:在方法上
特点:
在目标类的方法前后都能执行
能控制目标方法是否被调用执行
修改原来目标方法的执行结果,影响最后的调用结果
对切面方法的要求:
public;
必须有一个返回值,推荐使用Object;
方法名称自定义;
方法有参数,固定参数:ProceedingJoinPoint;其是JoinPoint接口的子接口,所以它能用JoinPoint中的所有方法
public class MyArroundImpl implements MyArround{
public Integer doSome() {
System.out.println("目标");
return 1;
}
}
?
public class MyArroundAspect {
//可在(1)处添加pjp.getArgs()方法,获取目标方法的实参,可修改实参,也可通过实参判断是否继续执行目标方法,即(2)
//(2)为执行目标方法,获取目标方法的返回值
//在(3)处可对目标方法的返回值进行修改,这是会影响最后在测试程序中的输出结果的。与@AfterReturning中的不同
属性:value值(切入点表达式);
throwing:自定义变量,表示目标方法抛出的异常(自定义的变量名必须和通知(切面)方法的形参名一样)
位置:在方法上
特点:在目标类方法抛出异常时执行;
可以做异常的监控程序,监控目标方法执行时是不是有异常
对切面方法的要求:要是public;要是void;方法名称自定义;参数只有一个:Exception,若还有就是JoinPoint
//常用的方法:
System.out.println(ex.getMessage()); //打印异常信息
属性:value值(切入点表达式)
位置:在方法上
特点:在目标方法之后执行
总是会执行,即使目标方法出现异常,也会执行(相当于try...catch中的finally)
所以一般用来做资源的关闭,清理工作
对切面方法的要求:要是public;要是void;方法名称自定义;方法没有参数,若有就是JoinPoint
//若目标类有接口时,使用的jdk代理。被转换的目标类的类型是:com.sun.proxy.$Proxy6
//若目标程序没有接口,spring框架就自动使用cglib代理,不需要程序员操作;被转换的目标类的类型是: com.dh.arround.MyArroundImpl$$EnhancerBySpringCGLIB$$8fc79603
//若目标类有接口,但你希望用cglib代理,则将<aop:aspectj-autoproxy/>自动代理生成器改为:
<aop:aspectj-autoproxy proxy-target-class="true"/>
//使用cglib代理的前提是:目标类能被继承
<!--Druid会自动跟url识别驱动类名,如果连接的数据库非常见数据库,配置属性driverClassName-->
<bean id="aaa" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
<property name="url" value="jdbc:mysql://localhost:3306/test" />
<property name="username" value="root" />
<property name="password" value="123456" />
<property name="maxActive" value="20" />
</bean>
?
<bean id="bbb" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="aaa"/>
<property name="configLocation" value="classpath:mybatis.xml"/>
</bean>
//通过连接数据源aaa和连接mybatis配置文件,在内部先创建SqlSessionFactory对象,然后再由该对象创建SqlSession对象
//因为是<property>标签,则com.alibaba.druid.pool.DruidDataSource类中一定有url属性对应的set()方法,因为该标签是通过set注入完成赋值的
<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="sqlSessionFactoryBeanName" value="bbb"/>
<property name="basePackage" value="com.dh.dao"/>
</bean>
//MapperScannerConfigurer类会在内部调用getMapper()方法,生成每个dao接口的代理对象
//MapperScannerConfigurer会扫描basePackage指定的包中所有的接口,把每个接口都执行一次getMapper()方法,得到每个接口的dao对象,再把创建好的dao对象放进spring容器中
//dao对象的默认名称:接口名首字母小写
<bean id="ccc" class="com.dh.service.impl.StudentServiceImpl">
<property name="studentDao" ref="studentDao"/>
</bean>
//此时给StudentServiceImpl类中的studentDao属性赋值的就是上面那步生成的dao对象
String conf="applicationContext.xml";
ApplicationContext ac=new ClassPathXmlApplicationContext(conf);
StudentService ss=(StudentService)ac.getBean("ccc");
?
Student student=new Student();
student.setId(7);
student.setName("gg");
student.setAge(44);
student.setSex(0);
int nums=ss.addStudent(student);
List<Student> list=ss.queryStudent();
for(Student stu:list){
System.out.println(stu);
}
//注意:此时的ss对象是com.dh.service.impl.StudentServiceImpl,而不是com.sun.proxy.$Proxy类型,在aop中才是
//spring与mybatis整合中常通过在service中声明dao对象,并给其赋值,再用其在service类中调用dao接口中的方法;而不是直接使用dao对象调用dao接口中的方法,中间要转一下
//spring与mybatis整合在一起使用时,事务是自动提交的,无需手动写SqlSession.commit()
//不同的数据库访问技术处理事务的方式不同,比如:jdbc(conn.commit())与mybatis(SqlSession.commit())处理事务的方式就不同,所以spring就提供了一种事务处理的统一模型,能用统一的步骤,方式完成多种不同数据库访问技术的事务处理
//即PlatformTransactionManager接口,封装了commit,rollback等方法
//该接口为每一种数据访问技术都设置好了专门的实现类,比如mybatis对应的实现类是DataSourceTransactionManager
//即TransactionDefinition接口
//该接口中定义了事务描述相关的三类常量:事务的隔离级别,事务的传播行为,事务默认的超时时限
隔离级别 | 含义 |
---|---|
ISOLATION_DEFAULT | 默认, 使用后端数据库默认的隔离级别 |
ISOLATION_READ_UNCOMMITTED | 读未提交, 允许读取尚未提交的更改。可能导致脏读、幻读或不可重复读。 |
ISOLATION_READ_COMMITTED | 读已提交,允许从已经提交的并发事务读取。可防止脏读,但幻读和不可重复读仍可能会发生。(Oracle 默认级别) |
ISOLATION_REPEATABLE_READ | 可重复读, 对相同字段的多次读取的结果是一致的,除非数据被当前事务本身改变。可防止脏读和不可重复读,但幻读仍可能发生。(MYSQL默认级别) |
ISOLATION_SERIALIZABLE | 串行化, 完全服从ACID的隔离级别,确保不发生脏读、不可重复读和幻影读。这在所有隔离级别中也是最慢的,因为它通常是通过完全锁定当前事务所涉及的数据表来完成的。 |
//只用掌握前面3种传播行为即可
传播行为 | 含义 |
---|---|
PROPAGATION_REQUIRED | 指定的方法必须在事务内运行。若当前存在事务,就加入到当前事务中;若当前没有事务,则创建一个新的事务。这是spring默认的传播行为 |
PROPAGATION_SUPPORTS | 表示指定的当前方法可以在没有事务上下文中执行;但是如果存在当前事务的话,那么该方法会在这个事务中运行。查询操作就属于这种行为 |
PROPAGATION_REQUIRES_NEW | 指定的方法总是会新建一个事务,并在新建的事务中执行,若当前存在事务,则将当前事务挂起(即暂停,包括事务中所有的方法也暂停),直到新事务执行完毕 |
//PROPAGATION_REQUIRED实例解释:PROPAGATION_REQUIRED传播行为指定方法a,若B事务中的b方法调用方法a,则a方法加入到B事务中执行;若没有事务的b方法调用a方法。则a方法自己新建一个事务c,在事务c中执行
传播行为 | 含义 |
---|---|
PROPAGATION_MANDATORY | 表示该方法必须在事务中运行,如果当前事务不存在,则会抛出一个异常 |
PROPAGATION_NOT_SUPPORTED | 表示该方法不应该运行在事务中。如果存在当前事务,在该方法运行期间,当前事务将被挂起。如果使用JTATransactionManager的话,则需要访问TransactionManager |
PROPAGATION_NEVER | 表示当前方法不应该运行在事务上下文中。如果当前正有一个事务在运行,则会抛出异常 |
PROPAGATION_NESTED | 表示如果当前已经存在一个事务,那么该方法将会在嵌套事务中运行。嵌套的事务可以独立于当前事务进行单独地提交或回滚。如果当前事务不存在,那么其行为与PROPAGATION_REQUIRED一样。注意各厂商对这种传播行为的支持是有所差异的。可以参考资源管理器的文档来确认它们是否支持嵌套事务 |
//为了避免一个事务长时间占有资源,所以设置了超时时间
//表示一个方法或事务的最长执行时间,如果方法执行时间超过了这个超时时限,事务就会回滚。单位是:秒,整数值,默认值为:-1
//当你的业务方法,执行成功,没有异常抛出,spring会在方法执行完毕,自动调用事务管理器中的commit方法,提交事务
//在默认设置下,事务只在出现运行时异常(Runtimeexception及其子类)或者错误(Error)时回滚事务,而在出现非运行异常时,主要是受检查异常(checked exception)时不回滚,自动调用事务管理器中的commit方法提交事务。
//不过可以人为设置,主动声明在出现特定受检查异常时像运行时异常一样回滚。同样,也可以声明一个事务在出现特定的异常时不回滚,即使特定的异常是运行时异常。
//适用于中小型项目
//该注解使用的是spring框架中内部的aop来处理事务
//@Transactional注解只能用于public方法,用在非public方法时,Spring虽然不会报错,但不会将该方法纳入该事物中,因为spring会忽略所有非public方法上的@Transactional注解
propagation:事务传播行为设置,该属性类型为Propagation枚举(7个),默认值为:Propagation_REQUIRED
isolation:事务隔离级别设置,该属性类型为Isolation枚举(5个),默认值为Isolation_DEFAULT
readOnly:设置目标方法对数据库的操作是否只能读事务,而不能读写事务。该属性为boolean,默认读写,即为false
timeout:事务超时时间设置。单位:秒,类型为:int,默认值为:-1,即没有时限
rollbackFor:指定需要回滚的异常类(即出现该指定异常类后,该事物回滚,即使该异常属于非运行时异常)。类型为Class[],默认值为空数组,当只有一个异常类时,可以不使用数组。
rollbackForClassName:指定需要回滚的异常类的类名,类型为String[],默认值为空数组,当只有一个异常类时,可以不使用数组。
noRollbackFor:指定不需要回滚的异常类(即出现该指定异常类后,该事物不回滚,即使该异常属于运行时异常)。类型为Class[],默认值为空数组,当只有一个异常类时,可以不使用数组。
noRollbackForClassName:指定不需要回滚的异常类的类名,类型为String[],默认值为空数组,当只有一个异常类时,可以不使用数组。
value :可选的限定描述符,指定使用的事务管理器
//spring使用aop机制,创建该注解所在类的代理对象,给方法加入事务功能。使用了环绕通知的方式,在目标业务方法执行之前,在切面方法中开启事务,在业务方法结束之后,提交事务,或者出现异常时,回滚事务,这一切都有spring框架自动控制。
<bean id="ccc" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="aaa"/>
</bean>
<tx:annotation-driven transaction-manager="ccc"/>
//声明事务管理器并注册,是对数据源声明事物管理器
//ref属性:其值为数据源的id值,在这里是阿里的那个数据源
//<tx:annotation-driven transaction-manager="ccc"/>:是对事务管理器进行注册,创建代理对象
//适合大型项目
//使用aspectj框架功能在spring的配置文件中声明类,方法的事务。这种方式业务方法与事务配置是完全分离的
//使用该方式需要在提前加入aspectj的依赖
声明事务管理器
<bean id="ccc" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="aaa"/>
</bean>
声明业务方法的业务属性
<tx:advice id="ddd" transaction-manager="ccc">
<tx:attributes>
<tx:method name="buy" propagation="REQUIRED"
isolation="DEFAULT" read-only="true"
rollback-for="java.lang.NullPointerException"
......
/>
</tx:attributes>
</tx:advice>
//name:方法名称(不包含包名和类名),可以使用完整方法名,也可以用通配符 “*” 。当你需要给成千上万的方法配置事务时,你可以给同一类的方法命名时有共同点,比如:以字母abc开头,则在name属性处填:abc*,就能给这一类的方法添加事务;当name=“*”,表示给目标类中所有方法都添加该事物
//当有一方法同时满足完整方法名,带有*的方法名,以及只有*,即这三种情况同时存在时,spring会先匹配完成方法名,当匹配成功后,就不再匹配其他的;只有匹配失败,再去带有*的方法名中找,前两者都没匹配成功,才执行只有*的事务配置
配置aop
<aop:config>
<aop:pointcut id="eee" expression="execution(void buy(..))"/>
<aop:advisor advice-ref="ddd" pointcut-ref="eee"/>
</aop:config>
//<aop:pointcut>:配置切入点表达式,指定哪些包中的类需要使用业务(即目标业务方法所在的包名,类名)
//<aop:advisor>:配置增强器,关联advice和pointcut(连接目标类和业务方法)
标签:pointer 字符 out 持久层 int 起点 容器 循环 struct
原文地址:https://www.cnblogs.com/zhestudy-2021/p/14961117.html