作者:小傅哥

博客:https://bugstack.cn - 《手写Mybatis系列》

一、前言

为什么,要读框架源码?

因为手里的业务工程代码太拉胯了!通常作为业务研发,所开发出来的代码,大部分都是一连串的流程化处理,缺少功能逻辑的解耦,有着迭代频繁但可迭代性差的特点。所以这样的代码通常只能学习业务逻辑,却很难吸收到大型系统设计和功能逻辑实现的成功经验,往往都是失败的教训。

而所有系统的设计和实现,核心都在于如何解耦,如果解耦不清晰最后直接导致的就是再继续迭代功能时,会让整个系统的实现越来越臃肿,稳定性越来越差。而关于解耦的实践在各类框架的源码中都有非常不错的设计实现,所以阅读这部分源码,就是在吸收成功的经验。把解耦的思想逐步运用到实际的业务开发中,才会让我们写出更加优秀的代码结构。

二、目标

在上一章节我们实现了有/无连接池的数据源,可以在调用执行SQL的时候,通过我们实现池化技术完成数据库的操作。

那么关于池化数据源的调用、执行和结果封装,目前我们还都只是在 DefaultSqlSession 中进行发起 如图 7-1 所示。那么这样的把代码流程写死的方式肯定不合适于我们扩展使用,也不利于 SqlSession 中每一个新增定义的方法对池化数据源的调用。

  • 解耦 DefaultSqlSession#selectOne 方法中关于对数据源的调用、执行和结果封装,提供新的功能模块替代这部分硬编码的逻辑处理。
  • 只有提供了单独的执行方法入口,我们才能更好的扩展和应对这部分内容里的需求变化,包括了各类入参、结果封装、执行器类型、批处理等,来满足不同样式的用户需求,也就是配置到 Mapper.xml 中的具体信息。

三、设计

从我们对 ORM 框架渐进式的开发过程上,可以分出的执行动作包括,解析配置、代理对象、映射方法等,直至我们前面章节对数据源的包装和使用,只不过我们把数据源的操作硬捆绑到了 DefaultSqlSession 的执行方法上了。

那么现在为了解耦这块的处理,则需要单独提出一块执行器的服务功能,之后将执行器的功能随着 DefaultSqlSession 创建时传入执行器功能,之后具体的方法调用就可以调用执行器来处理了,从而解耦这部分功能模块。如图 7-2 所示。

  • 首先我们要提取出执行器的接口,定义出执行方法、事务获取和相应提交、回滚、关闭的定义,同时由于执行器是一种标准的执行过程,所以可以由抽象类进行实现,对过程内容进行模板模式的过程包装。在包装过程中定义抽象类,由具体的子类来实现。这一部分在下文的代码中会体现到 SimpleExecutor 简单执行器实现中。
  • 之后是对 SQL 的处理,我们都知道在使用 JDBC 执行 SQL 的时候,分为了简单处理和预处理,预处理中包括准备语句、参数化传递、执行查询,以及最后的结果封装和返回。所以我们这里也需要把 JDBC 这部分的步骤,分为结构化的类过程来实现,便于功能的拓展。具体代码主要体现在语句处理器 StatementHandler 的接口实现中。

四、实现

1. 工程结构

mybatis-step-06
└── src
├── main
│ └── java
│ └── cn.bugstack.mybatis
│ ├── binding
│ │ ├── MapperMethod.java
│ │ ├── MapperProxy.java
│ │ ├── MapperProxyFactory.java
│ │ └── MapperRegistry.java
│ ├── builder
│ ├── datasource
│ ├── executor
│ │ ├── resultset
│ │ │ ├── DefaultResultSetHandler.java
│ │ │ └── ResultSetHandler.java
│ │ ├── statement
│ │ │ ├── BaseStatementHandler.java
│ │ │ ├── PreparedStatementHandler.java
│ │ │ ├── SimpleStatementHandler.java
│ │ │ └── StatementHandler.java
│ │ ├── BaseExecutor.java
│ │ ├── Executor.java
│ │ └── SimpleExecutor.java
│ ├── io
│ ├── mapping
│ ├── session
│ │ ├── defaults
│ │ │ ├── DefaultSqlSession.java
│ │ │ └── DefaultSqlSessionFactory.java
│ │ ├── Configuration.java
│ │ ├── ResultHandler.java
│ │ ├── SqlSession.java
│ │ ├── SqlSessionFactory.java
│ │ ├── SqlSessionFactoryBuilder.java
│ │ └── TransactionIsolationLevel.java
│ ├── transaction
│ └── type
└── test
├── java
│ └── cn.bugstack.mybatis.test.dao
│ ├── dao
│ │ └── IUserDao.java
│ ├── po
│ │ └── User.java
│ └── ApiTest.java
└── resources
├── mapper
│ └──User_Mapper.xml
└── mybatis-config-datasource.xml

SQL方法执行器核心类关系,如图 7-3 所示

  • 以 Executor 接口定义为执行器入口,确定出事务和操作和 SQL 执行的统一标准接口。并以执行器接口定义实现抽象类,也就是用抽象类处理统一共用的事务和执行SQL的标准流程,也就是这里定义的执行 SQL 的抽象接口由子类实现。
  • 在具体的简单 SQL 执行器实现类中,处理 doQuery 方法的具体操作过程。这个过程中则会引入进来 SQL 语句处理器的创建,创建过程仍有 configuration 配置项提供。你会发现很多这样的生成处理,都来自于配置项
  • 当执行器开发完成以后,接下来就是交给 DefaultSqlSessionFactory 开启 openSession 的时候随着构造函数参数传递给 DefaultSqlSession 中,这样在执行 DefaultSqlSession#selectOne 的时候就可以调用执行器进行处理了。也就由此完成解耦操作了。

2. 执行器的定义和实现

执行器分为接口、抽象类、简单执行器实现类三部分,通常在框架的源码中对于一些标准流程的处理,都会有抽象类的存在。它负责提供共性功能逻辑,以及对接口方法的执行过程进行定义和处理,并提取抽象接口交由子类实现。这种设计模式也被定义为模板模式。

2.1 Executor

源码详见cn.bugstack.mybatis.executor.Executor

public interface Executor {

    ResultHandler NO_RESULT_HANDLER = null;

    <E> List<E> query(MappedStatement ms, Object parameter, ResultHandler resultHandler, BoundSql boundSql);

    Transaction getTransaction();

    void commit(boolean required) throws SQLException;

    void rollback(boolean required) throws SQLException;

    void close(boolean forceRollback);

}
  • 在执行器中定义的接口包括事务相关的处理方法和执行SQL查询的操作,随着后续功能的迭代还会继续补充其他的方法。

2.2 BaseExecutor 抽象基类

源码详见cn.bugstack.mybatis.executor.BaseExecutor

public abstract class BaseExecutor implements Executor {

    protected Configuration configuration;
protected Transaction transaction;
protected Executor wrapper; private boolean closed; protected BaseExecutor(Configuration configuration, Transaction transaction) {
this.configuration = configuration;
this.transaction = transaction;
this.wrapper = this;
} @Override
public <E> List<E> query(MappedStatement ms, Object parameter, ResultHandler resultHandler, BoundSql boundSql) {
if (closed) {
throw new RuntimeException("Executor was closed.");
}
return doQuery(ms, parameter, resultHandler, boundSql);
} protected abstract <E> List<E> doQuery(MappedStatement ms, Object parameter, ResultHandler resultHandler, BoundSql boundSql); @Override
public void commit(boolean required) throws SQLException {
if (closed) {
throw new RuntimeException("Cannot commit, transaction is already closed");
}
if (required) {
transaction.commit();
}
} }
  • 在抽象基类中封装了执行器的全部接口,这样具体的子类继承抽象类后,就不用在处理这些共性的方法。与此同时在 query 查询方法中,封装一些必要的流程处理,如果检测关闭等,在 Mybatis 源码中还有一些缓存的操作,这里暂时剔除掉,以核心流程为主。读者伙伴在学习的过程中可以与源码进行对照学习。

2.3 SimpleExecutor 简单执行器实现

源码详见cn.bugstack.mybatis.executor.SimpleExecutor

public class SimpleExecutor extends BaseExecutor {

    public SimpleExecutor(Configuration configuration, Transaction transaction) {
super(configuration, transaction);
} @Override
protected <E> List<E> doQuery(MappedStatement ms, Object parameter, ResultHandler resultHandler, BoundSql boundSql) {
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, resultHandler, boundSql);
Connection connection = transaction.getConnection();
Statement stmt = handler.prepare(connection);
handler.parameterize(stmt);
return handler.query(stmt, resultHandler);
} catch (SQLException e) {
e.printStackTrace();
return null;
}
} }
  • 简单执行器 SimpleExecutor 继承抽象基类,实现抽象方法 doQuery,在这个方法中包装数据源的获取、语句处理器的创建,以及对 Statement 的实例化和相关参数设置。最后执行 SQL 的处理和结果的返回操作。
  • 关于 StatementHandler 语句处理器的实现,接下来介绍。

3. 语句处理器

语句处理器是 SQL 执行器中依赖的部分,SQL 执行器封装事务、连接和检测环境等,而语句处理器则是准备语句、参数化传递、执行 SQL、封装结果的处理。

3.1 StatementHandler

源码详见cn.bugstack.mybatis.executor.statement.StatementHandler

public interface StatementHandler {

    /** 准备语句 */
Statement prepare(Connection connection) throws SQLException; /** 参数化 */
void parameterize(Statement statement) throws SQLException; /** 执行查询 */
<E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException; }
  • 语句处理器的核心包括了;准备语句、参数化传递参数、执行查询的操作,这里对应的 Mybatis 源码中还包括了 update、批处理、获取参数处理器等。

3.2 BaseStatementHandler 抽象基类

源码详见cn.bugstack.mybatis.executor.statement.BaseStatementHandler

public abstract class BaseStatementHandler implements StatementHandler {

    protected final Configuration configuration;
protected final Executor executor;
protected final MappedStatement mappedStatement; protected final Object parameterObject;
protected final ResultSetHandler resultSetHandler; protected BoundSql boundSql; public BaseStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, ResultHandler resultHandler, BoundSql boundSql) {
this.configuration = mappedStatement.getConfiguration();
this.executor = executor;
this.mappedStatement = mappedStatement;
this.boundSql = boundSql; // 参数和结果集
this.parameterObject = parameterObject;
this.resultSetHandler = configuration.newResultSetHandler(executor, mappedStatement, boundSql);
} @Override
public Statement prepare(Connection connection) throws SQLException {
Statement statement = null;
try {
// 实例化 Statement
statement = instantiateStatement(connection);
// 参数设置,可以被抽取,提供配置
statement.setQueryTimeout(350);
statement.setFetchSize(10000);
return statement;
} catch (Exception e) {
throw new RuntimeException("Error preparing statement. Cause: " + e, e);
}
} protected abstract Statement instantiateStatement(Connection connection) throws SQLException; }
  • 在语句处理器基类中,将参数信息、结果信息进行封装处理。不过暂时这里我们还不会做过多的参数处理,包括JDBC字段类型转换等。这部分内容随着我们整个执行器的结构建设完毕后,再进行迭代开发。
  • 之后是对 BaseStatementHandler#prepare 方法的处理,包括定义实例化抽象方法,这个方法交由各个具体的实现子类进行处理。包括;SimpleStatementHandler 简单语句处理器和 PreparedStatementHandler 预处理语句处理器。
    • 简单语句处理器只是对 SQL 的最基本执行,没有参数的设置。
    • 预处理语句处理器则是我们在 JDBC 中使用的最多的操作方式,PreparedStatement 设置 SQL,传递参数的设置过程。

3.3 PreparedStatementHandler 预处理语句处理器

源码详见cn.bugstack.mybatis.executor.statement.PreparedStatementHandler

public class PreparedStatementHandler extends BaseStatementHandler{

    @Override
protected Statement instantiateStatement(Connection connection) throws SQLException {
String sql = boundSql.getSql();
return connection.prepareStatement(sql);
} @Override
public void parameterize(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.setLong(1, Long.parseLong(((Object[]) parameterObject)[0].toString()));
} @Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
return resultSetHandler.<E> handleResultSets(ps);
} }
  • 在预处理语句处理器中包括 instantiateStatement 预处理 SQL、parameterize 设置参数,以及 query 查询的执行的操作。
  • 这里需要注意 parameterize 设置参数中还是写死的处理,后续这部分再进行完善。
  • query 方法则是执行查询和对结果的封装,结果的封装目前也是比较简单的处理,只是把我们前面章节中对象的内容摘取出来进行封装,这部分暂时没有改变。都放在后续进行完善处理。

4. 执行器创建和使用

执行器开发完成以后,则需要在串联到 DefaultSqlSession 中进行使用,那么这个串联过程就需要在 创建 DefaultSqlSession 的时候,构建出执行器并作为参数传递进去。那么这块就涉及到 DefaultSqlSessionFactory#openSession 的处理。

4.1 开启执行器

源码详见cn.bugstack.mybatis.session.defaults.DefaultSqlSessionFactory

public class DefaultSqlSessionFactory implements SqlSessionFactory {

    private final Configuration configuration;

    public DefaultSqlSessionFactory(Configuration configuration) {
this.configuration = configuration;
} @Override
public SqlSession openSession() {
Transaction tx = null;
try {
final Environment environment = configuration.getEnvironment();
TransactionFactory transactionFactory = environment.getTransactionFactory();
tx = transactionFactory.newTransaction(configuration.getEnvironment().getDataSource(), TransactionIsolationLevel.READ_COMMITTED, false);
// 创建执行器
final Executor executor = configuration.newExecutor(tx);
// 创建DefaultSqlSession
return new DefaultSqlSession(configuration, executor);
} catch (Exception e) {
try {
assert tx != null;
tx.close();
} catch (SQLException ignore) {
}
throw new RuntimeException("Error opening session. Cause: " + e);
}
} }
  • 在 openSession 中开启事务传递给执行器的创建,关于执行器的创建具体可以参考 configuration.newExecutor 代码,这部分没有太多复杂的逻辑。读者可以参考源码进行学习。
  • 在执行器创建完毕后,则是把参数传递给 DefaultSqlSession,这样就把整个过程串联起来了。

4.2 使用执行器

源码详见cn.bugstack.mybatis.session.defaults.DefaultSqlSession

public class DefaultSqlSession implements SqlSession {

    private Configuration configuration;
private Executor executor; public DefaultSqlSession(Configuration configuration, Executor executor) {
this.configuration = configuration;
this.executor = executor;
} @Override
public <T> T selectOne(String statement, Object parameter) {
MappedStatement ms = configuration.getMappedStatement(statement);
List<T> list = executor.query(ms, parameter, Executor.NO_RESULT_HANDLER, ms.getBoundSql());
return list.get(0);
} }
  • 好了,经过上面执行器的所有实现完成后,接下来就是解耦后的调用了。在 DefaultSqlSession#selectOne 中获取 MappedStatement 映射语句类后,则传递给执行器进行处理,那么现在这个类经过设计思想的解耦后,就变得更加赶紧整洁了,也就是易于维护和扩展了。

五、测试

1. 事先准备

1.1 创建库表

创建一个数据库名称为 mybatis 并在库中创建表 user 以及添加测试数据,如下:

CREATE TABLE
USER
(
id bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID',
userId VARCHAR(9) COMMENT '用户ID',
userHead VARCHAR(16) COMMENT '用户头像',
createTime TIMESTAMP NULL COMMENT '创建时间',
updateTime TIMESTAMP NULL COMMENT '更新时间',
userName VARCHAR(64),
PRIMARY KEY (id)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8; insert into user (id, userId, userHead, createTime, updateTime, userName) values (1, '10001', '1_04', '2022-04-13 00:00:00', '2022-04-13 00:00:00', '小傅哥');

1.2 配置数据源

<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://127.0.0.1:3306/mybatis?useUnicode=true"/>
<property name="username" value="root"/>
<property name="password" value="123456"/>
</dataSource>
</environment>
</environments>
  • 通过 mybatis-config-datasource.xml 配置数据源信息,包括:driver、url、username、password
  • 在这里 dataSource 可以按需配置成 DRUID、UNPOOLED 和 POOLED 进行测试验证。

1.3 配置Mapper

<select id="queryUserInfoById" parameterType="java.lang.Long" resultType="cn.bugstack.mybatis.test.po.User">
SELECT id, userId, userName, userHead
FROM user
where id = #{id}
</select>
  • 这部分暂时不需要调整,目前还只是一个入参的类型的参数,后续我们全部完善这部分内容以后,则再提供更多的其他参数进行验证。

2. 单元测试

@Test
public void test_SqlSessionFactory() throws IOException {
// 1. 从SqlSessionFactory中获取SqlSession
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config-datasource.xml"));
SqlSession sqlSession = sqlSessionFactory.openSession(); // 2. 获取映射器对象
IUserDao userDao = sqlSession.getMapper(IUserDao.class); // 3. 测试验证
User user = userDao.queryUserInfoById(1L);
logger.info("测试结果:{}", JSON.toJSONString(user));
}
  • 在单元测试中没有什么变化,只是我们仍旧是传递一个 1L 的 long 类型参数,进行方法的调用处理。通过单元测试验证执行器的处理过程,读者在学习的过程中可以进行断点测试,学习每个过程的处理内容。

测试结果

22:16:25.770 [main] INFO  c.b.m.d.pooled.PooledDataSource - PooledDataSource forcefully closed/removed all connections.
22:16:26.076 [main] INFO c.b.m.d.pooled.PooledDataSource - Created connection 540642172.
22:16:26.198 [main] INFO cn.bugstack.mybatis.test.ApiTest - 测试结果:{"id":1,"userHead":"1_04","userId":"10001","userName":"小傅哥"} Process finished with exit code 0

  • 从测试结果看我们已经可以把 DefaultSqlSession#selectOne 中的调用,换成执行器完成整个过程的处理了,解耦了这部分的逻辑操作,也能方便我们后续的扩展。

六、总结

  • 整个章节的实现都是在处理解耦这件事情,从 DefaultSqlSession#selectOne 对数据源的处理解耦到执行器中进行操作。而执行器中又包括了对 JDBC 处理的拆解,链接、准备语句、封装参数、处理结果,所有的这些过程经过解耦后的类和方法,就都可以在以后的功能迭代中非常方便的完成扩展了。
  • 本章节也为我们后续扩展参数的处理、结果集的封装预留出了扩展点,以及对于不同的语句处理器选择的问题,都需要在后续进行完善和补充。目前我们串联出来的是最核心的骨架结构,随着后续的渐进式开发陆续迭代完善。
  • 对于源码的学习,读者要经历看、写、思考、应用等几个步骤的过程,才能更好的吸收这里面的思想,不只是照着CP一遍就完事了,否则也就失去了跟着学习源码的意义。

七、系列推荐

《Mybatis 手撸专栏》第7章:SQL执行器的定义和实现的更多相关文章

  1. 《Mybatis 手撸专栏》第1章:开篇介绍,我要带你撸 Mybatis 啦!

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 1. 为甚,撸Mybatis 我就知道,你会忍不住对它下手! 21年带着粉丝伙伴撸了一遍 Sp ...

  2. 《Mybatis 手撸专栏》第9章:细化XML语句构建器,完善静态SQL解析

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 你只是在解释过程,而他是在阐述高度! 如果不是长时间的沉淀.积累和储备,我一定也没有 ...

  3. 《Mybatis 手撸专栏》第10章:使用策略模式,调用参数处理器

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 你这代码写的,咋这么轴呢! 说到轴,让我想起初中上学时老师说的话:"你那脑 ...

  4. 《Mybatis 手撸专栏》第6章:数据源池化技术实现

    作者:小傅哥 博客:https://bugstack.cn - 手写Mybatis系列文章 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 码农,只会做不会说? 你有发现吗,其实很大一部分码农 ...

  5. 《Mybatis 手撸专栏》第8章:把反射用到出神入化

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 为什么,读不懂框架源码? 我们都知道作为一个程序员,如果想学习到更深层次的技术,就需 ...

  6. 《Spring 手撸专栏》第 3 章:初显身手,运用设计模式,实现 Bean 的定义、注册、获取

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 你是否能预见复杂内容的设计问题? 讲道理,无论产品功能是否复杂,都有很大一部分程序员 ...

  7. 《Spring 手撸专栏》第 2 章:小试牛刀(让新手能懂),实现一个简单的Bean容器

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 上学时,老师总说:不会你就问,但多数时候都不知道要问什么! 你总会在小傅哥的文章前言 ...

  8. 【分布式锁的演化】终章!手撸ZK分布式锁!

    前言 这应该是分布式锁演化的最后一个章节了,相信很多小伙伴们看完这个章节之后在应对高并发的情况下,如何保证线程安全心里肯定也会有谱了.在实际的项目中也可以参考一下老猫的github上的例子,当然代码没 ...

  9. 2022年整理最详细的java面试题、掌握这一套八股文、面试基础不成问题[吐血整理、纯手撸]

    这里是参考B站上的大佬做的面试题笔记.大家也可以去看视频讲解!!! 文章目录 1.面向对象 2.JDK.JRE.JVM区别和联系 3.==和equals 4.final 5.String .Strin ...

随机推荐

  1. 是否使用过 Redis 集群,集群的原理是什么?

    1).Redis Sentinal 着眼于高可用,在 master 宕机时会自动将 slave 提升为 master,继续提供服务. 2).Redis Cluster 着眼于扩展性,在单个 redis ...

  2. Java 中,受检查异常 和 不受检查异常的区别?

    受检查异常编译器在编译期间检查.对于这种异常,方法强制处理或者通过 throws 子句声明.其中一种情况是 Exception 的子类但不是 RuntimeException 的子类.非受检查是 Ru ...

  3. Centos最小化安装

    1.选择最小安装: 2.选择相应的安装包 老男孩提示: 1.根据经验,选择安装包时应该按最小化原则,即不需要的或者不确定是否需要的就不安装,这样可以最大程度上确保系统安全. 2.如果安装过程落了部分包 ...

  4. 如何将springboot工程打包成war包并且启动

    将项目打成war包,放入tomcat 的webapps目录下面,启动tomcat,即 可访问. 1.pom.xml配置修改 <packaging>jar</packaging> ...

  5. C语言思维导图—自己整理的

  6. 安装Backstage.io应用

    Backstage介绍 What's Backstage? Backstage is an open platform for building developer portals. Powered ...

  7. CSS中宽度与高度

    div的高度 div由的高度是由它里面的字体乘以字体建议的行高确定,跟这个字体大小没有关系(不同字体相同字体大小,会影响div的高度.)如过明确告诉浏览器行高,div高度就是行高. 文字两端对齐: 文 ...

  8. 总结一下各种0.5px的线

    在PC端用1px的边框线,看起来还好,但在手机端看起来就很难看了,而0.5px的分割线会有种精致的感觉.用普通写法border:solid 0.5px red;iPhone可以正常显示,android ...

  9. React-简单通用的抛物线动画

    一个简单通用的 React 抛物线动画demo Usage import { parabola } from "./parabola" ... onAnimate = () =&g ...

  10. angular组件开发

    项目中经常会有一些公共组件,比如header,如果每个页面都写一遍的话显得很冗余,而且不利于维护,这时候我们就会考虑将这些公共部分抽取出来,做成一个单独的组件. 然而angular不是很熟悉啊~怎么啵 ...