mybatis-plus 浅析

基本使用

引入pom:

如果是 springboot3 中必须手动指定mybatis-spring的版本 3.X, 否则无法启动。(默认引入的mybatis-spring为2.X版本),

xml 复制代码
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.5.14</version>
  <exclusions>
    <exclusion>
      <groupId>org.mybatis</groupId>
      <artifactId>mybatis-spring</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.mybatis</groupId>
  <artifactId>mybatis-spring</artifactId>
  <version>3.0.5</version>
</dependency>

创建实体类:

less 复制代码
@Data
@TableName
public class Student {
    @TableId(type = IdType.AUTO)
    private Integer id;

    private String stu_code;
    private Integer age;
}

创建Mapper接口:

java 复制代码
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface StudentMapper extends BaseMapper<Student> {
}

Springboot启动类添加:

@MapperScan(basePackageClasses = StudentMapper.class)

配置文件添加SQL日志打印:输出到控制台

yaml 复制代码
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

controller 注入Mapper接口即可:

kotlin 复制代码
@Autowired
private StudentMapper studentMapper;
@GetMapping("/test")
public List<Student> test() {
    List<com.demo.boot2.mapper.Student> students =
    studentMapper.selectList(null);
    System.out.println(students);
    return null;
}

MybatisPlusAutoConfiguration:

mybatisplus 核心自动装配类。 主要构造SqlSessionFactory、SqlSessionTemplate

SqlSessionFactory

MybatisSqlSessionFactory 替换mybatis中的SqlSessionFactoryBean。

主要进行解析一些核心配置:类型转换器、拦截器、SQL注入器、ID生成器等。

factory.getObject方法会调用 MybatisSqlSessionFactoryBean#afterPropertiesSet。 解析XML、Mapper接口,最终构建SqlSessionFactoryBean。

解析XML

XMLMapperBuilder#parse: 解析mybatis的Mapper 配置文件。如果没有配置则不会走这里的逻辑

一般配置路径为:*classpath:/mapper/**/*.xml**

  1. configurationElement:

    这里会解析XML 文件 生成MappedStatement, 记录到MybatisConfiguration#mappedStatements, 同一个id 记录两条entry。

    XML 文件跟对应的Mapper接口不能同时定义二级缓存(没人这样干吧),因为最终保存到Configuration中,会发生覆盖冲突报错。

    解析XML 创建SqlSource:

    org.apache.ibatis.builder.xml.XMLStatementBuilder#parseStatementNode

    最终生成MappedStatement:

bash 复制代码
![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ef7c3ef1960f40249659cbae89c68424~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgeGlhb3llMjAxOA==:q75.awebp?rk3s=f64ab15b&x-expires=1762261351&x-signature=8gAIb7qLmnCy5dU17tsb9O4GUzY%3D "org.apache.ibatis.builder.xml.XMLStatementBuilder#parseStatementNode")  
ini 复制代码
最终生成MappedStatement:![](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/33614e063a3d4b2da98b9e4ef4784775~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgeGlhb3llMjAxOA==:q75.awebp?rk3s=f64ab15b&x-expires=1762261351&x-signature=CQcjuL5X1XP4pgbvQ6qm%2F5Ab7TM%3D)
  1. bindMapperForNamespace:

    解析当前XML的namespace 接口, 处理生成接口对应的Mapper对象

    记录到MybatisMapperRegistry#knownMappers

  2. 继续执行内部parse:

    1. 首先检查Mapper接口缓存相关定义:@CacheNamespace,@CacheNamespaceRef。

      如果定义了相关注解,创建缓存对象。默认PerpetualCache, 添加到Configuration

    1. 然后检查接口方法是否有mybtis注解:进行替换BaseMapper的CRUD方法。 MybatisMapperAnnotationBuilder#statementAnnotationTypes

    2. parseInjector:解析Entity为TableInfo对象, 同时注入Mybatis-plus 提供的默认CRUD 方法以Insert 为例: 会构建SQL 执行脚本,生成sqlSource对象, 创建MappedStatement 对象最后生成MappedStatement:

创建DefaultSqlSessionFactory

通过configuration创建DefaultSqlSessionFactory

构建SqlSessionTemplate

这里构建的ibatis中的SqlSessionTemplate

org.mybatis.spring.SqlSessionTemplate#SqlSessionTemplate

提供了一些常用的查询方法,都是通过sqlSessionProxy实现, 最终交给SqlSessionInterceptor

@MapperScan处理

import --> MapperScannerRegistrar:

注册beanDefinition: MapperScannerConfigurer

MapperScannerConfigurer 实现了BeanDefinitionRegistryPostProcessor, postProcessBeanDefinitionRegistry方法中会生成ClassPathMapperScanner对象进行扫描basePackage目录的接口类。

org.mybatis.spring.mapper.MapperScannerConfigurer#postProcessBeanDefinitionRegistry

把接口类扫描出来, 生成 MapperFactoryBean beanDefinition

org.mybatis.spring.mapper.ClassPathMapperScanner#processBeanDefinitions

这里的beanDefinition只设置了两个PropertyValue

Mapper代理对象属性注入

MapperFactoryBean继承了 SqlSessionDaoSupport, 里面包含几个set方法

Spring在填充属性的时候会尝试将带set方法、非简单参数类型的方法 尝试执行其setXXX。 会从IOC中获取参数类型的对象。

SqlSessionDaoSupport:

由于SqlSessionFactory、SqlSessionTemplate 在MybatisPlusAutoConfiguration中都已经定义了Bean,因此下面的setSqlSessionFactory、setSqlSessionTemplate都会执行一遍。

注意到setSqlSessionFactory中会重新创建sqlSessionTemplate。 这里是否多于了,为什么没有定义属性SqlSessionFactory

kotlin 复制代码
public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
if (this.sqlSessionTemplate == null || sqlSessionFactory != this.sqlSessionTemplate.getSqlSessionFactory()) {
    this.sqlSessionTemplate = createSqlSessionTemplate(sqlSessionFactory);
}
}
public final SqlSessionFactory getSqlSessionFactory() {
    return (this.sqlSessionTemplate != null ? this.sqlSessionTemplate.getSqlSessionFactory() : null);
}

public void setSqlSessionTemplate(SqlSessionTemplate sqlSessionTemplate) {
this.sqlSessionTemplate = sqlSessionTemplate;
}
 public SqlSession getSqlSession() {
    return this.sqlSessionTemplate;
}

afterPropertiesSet

当Bean对象初始化完成后,最后会调用afterPropertiesSet方法,完成最后的一些参数初始化。

这里会判断Configuration中是否已经有该接口对应的Mapper对象(当定义过相关XML配置的时候,这里不会继续处理), 生成Mapper接口对应的MappedStatement对象

org.mybatis.spring.mapper.MapperFactoryBean#checkDaoConfig

这里的逻辑实际上跟前面解析XML部分后期解析Mapper接口对应的逻辑是一样的。最终生成MappedStatement到MybatisConfiguration中

getObject

当bean实例化完成后,执行 #getObject方法, 构建Mapper代理对象 MybatisMapperProxy 作为最终的Bean

从SqlSessionTemplate中获取Mapper代理对象

com.baomidou.mybatisplus.core.MybatisMapperRegistry#getMapper

创建JDK代理对象:

Mapper方法执行流程

前面提到注入的DAO方法实际上是MybatisMapperProxy对象,在执行目标的时候会转到MybatisMapperMethod#execute,通过接口方法,配置判断属于哪类操作,进而分配到合适的执行方法


com.baomidou.mybatisplus.core.override.MybatisMapperMethod#execute

sqlSession 即SqlSessionTemplate, 最终委派给SqlSessionInterceptor

不管是mybatis-plus 内置的方法,还是mybatis xml定义的方法,最终都会在这里进行开启SqlSession

1. 开启SqlSession

org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor:

根据执行类型选择合适的执行器(默认为SimpleExecutor)

org.apache.ibatis.session.Configuration#newExecutor

2. Executor 执行目标

DefaultSqlSession:

查询方法都会进入select:

通过statement 从Configuration中得到MappedStatement(包含了缓存对象信息) , 交给Executor 查询

BoundSQL: 包含最终的SQL,以及参数信息

先查询二级缓存:

MappedStatement中记录了缓存对象,默认namespace 独有。(@CacheNamespaceRef 可以改变为非namespace)

org.apache.ibatis.executor.CachingExecutor#query

二级缓存中没有数据,查询一级缓存:

执行底层查询逻辑:

org.apache.ibatis.executor.SimpleExecutor#doQuery

prepareStatement: 获取Connection,生成PrepareStatement,填充参数

从SpringManagedTransaction对象 获取JDBC连接对象, 如果没有初始化链接,进行初始化。

DataSourceUtils.getConnection: 会先尝试从TransactionSynchronizationManager中获取链接,如果获取不到则从Datasource中新建链接对象。

autoCommit:会检查该链接是否是autoCommit。如果是则执行完成后不需要提交事务。

handler#query:

这里的ps 实际上是mybatis的代理对象,最终会调用底层JDBC API 进行查询

org.apache.ibatis.executor.statement.PreparedStatementHandler#query

3. 提交事务

org.apache.ibatis.executor.CachingExecutor#commit

将记录存入二级缓存。

Interceptor

类似AOP机制来拦截执行过程,可以进行执行耗时统计,日志记录,SQL修改等。

如下面对SQL添加注释前缀,用于记录一些SQL执行信息:

less 复制代码
@Intercepts({
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class LogQueryAndUpdateSqlHandler implements Interceptor {
    // 拦截时调用方法
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        return LogSqlHelper.intercept(invocation, this.slowSqlThreshold, this.isOptimizeSql);
    }

    // 指定Executor 目标才开始拦截
    @Override
    public Object plugin(Object target) {
        return target instanceof Executor ? Plugin.wrap(target, this) : target;
    }

    @Override
    public void setProperties(Properties properties) {
    }
}


// 拦截prepare
@Intercepts({
    @Signature(
        type = StatementHandler.class,
        method = "prepare",
        args = {Connection.class, Integer.class}
    )
})
public class SqlCommentInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 获取原始 SQL
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        String originalSql = handler.getBoundSql().getSql();

        // 2. 添加自定义注释前缀
        String commentedSql = "/* 业务标记 */ " + originalSql;

        // 3. 通过反射修改 SQL
        Field boundSqlField = handler.getBoundSql().getClass().getDeclaredField("sql");
        boundSqlField.setAccessible(true);
        boundSqlField.set(handler.getBoundSql(), commentedSql);

        // 4. 继续执行原逻辑
        return invocation.proceed();
    }
}

在创建Executor的时候会织入拦截器

除了Executor外,下面方法也都可以进行拦截处理

分页

  1. 使用MybatisPlus 自带的分页拦截器
ini 复制代码
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
    paginationInnerInterceptor.setDbType(DbType.MYSQL);
    paginationInnerInterceptor.setOverflow(true);
    interceptor.addInnerInterceptor(paginationInnerInterceptor);
    return interceptor;
}
  1. 使用pagehelper

引入依赖

xml 复制代码
<dependency>
  <groupId>com.github.pagehelper</groupId>
  <artifactId>pagehelper-spring-boot-starter</artifactId>
  <version>1.4.6</version>
</dependency>

PageHelperAutoConfiguration会自动注入PageInterceptor:

查询前使用:PageHelper.startPage(1, 2);

会将Page参数信息存入ThreadLocal中,在执行查询过程中,pageHelper的拦截器会自动处理相关分页信息。

执行结束后pageHelper会自动remove ThreadLocal的信息。

SQL 记录拦截器

mybatis 默认打印的SQL日志都是带参数的预编译SQL,不便于日志分析, 也没有记录执行时间。 demo来自:blog.csdn.net/weixin_4550...

typescript 复制代码
// 只拦截Executor的这两个方法, save 也是执行的update
@Intercepts({
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class LogQueryAndUpdateSqlHandler implements Interceptor {
 @Override
 public Object intercept(Invocation invocation) throws Throwable {
     return LogSqlHelper.intercept(invocation, this.slowSqlThreshold, this.isOptimizeSql);
 }

 @Override
 public Object plugin(Object target) {
     // 这里不判断也可以, 内部会判断 是否符合@Signature的type
     return  target instanceof Executor ? Plugin.wrap(target, this) : target;
 }
}
java 复制代码
package com.example.springbootadmin.mybatis;

public class LogSqlHelper {

    private static final Logger log = LoggerFactory.getLogger(LogSqlHelper.class);

    private static final String SELECT = "select";

    private static final String FROM = "from";

    private static final String SIMPLE_SELECT = "select * ";

    private static final int MAX_SQL_LENGTH = 120;

    private static final String PATTERN = "yyyy-MM-dd HH:mm:ss";

    public LogSqlHelper() {
    }

    public static Object intercept(Invocation invocation, int slowSqlThreshold, boolean optimizeSql) throws Throwable {
        long startTime = System.currentTimeMillis();
        Object returnValue = invocation.proceed();
        long cost = System.currentTimeMillis() - startTime;
        if (cost >= (long) slowSqlThreshold) {
            log.info("cost = {} ms, affected rows = {}, SQL: {}",
                     cost, formatResult(returnValue), formatSql(invocation, optimizeSql));
        }
        return returnValue;
    }

    private static Object formatResult(Object obj) {
        if (obj == null) {
            return "NULL";
        } else if (obj instanceof List) {
            return ((List) obj).size();
        } else if (!(obj instanceof Number) && !(obj instanceof Boolean) && !(obj instanceof Date)
                   && !(obj instanceof String)) {
            return obj instanceof Map ? ((Map) obj).size() : 1;
        } else {
            return obj;
        }
    }

    private static String formatSql(Invocation invocation, boolean isOptimizeSql) {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        Object parameter = null;
        if (invocation.getArgs().length > 1) { // 拦截的方法query、update 都是有多个参数的。
            parameter = invocation.getArgs()[1]; // SQL的参数对象
        }
        BoundSql boundSql = mappedStatement.getBoundSql(parameter);    // 通过参数对象重新构建BoundSql,这里会包含数据库生成的主键信息
        Configuration configuration = mappedStatement.getConfiguration();
        Object parameterObject = boundSql.getParameterObject();
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        String sql = boundSql.getSql().replaceAll("[\s]+", " ");
        String formatSql = sql.toLowerCase();
        if (isOptimizeSql && formatSql.startsWith(SELECT) && formatSql.length() > MAX_SQL_LENGTH) {
            sql = SIMPLE_SELECT + sql.substring(formatSql.indexOf(FROM));
        }
        // 通过参数对象填充SQL占位符
        if (parameterMappings.size() > 0 && parameterObject != null) {
            TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
            if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                sql = sql.replaceFirst("\?", formatParameterValue(parameterObject));
            } else {
                MetaObject metaObject = configuration.newMetaObject(parameterObject);
                for (ParameterMapping parameterMapping : parameterMappings) {
                    String propertyName = parameterMapping.getProperty();
                    Object obj;
                    if (metaObject.hasGetter(propertyName)) {
                        obj = metaObject.getValue(propertyName);
                     sql = sql.replaceFirst("\?", formatParameterValue(obj));
                 } else if (boundSql.hasAdditionalParameter(propertyName)) {
                     obj = boundSql.getAdditionalParameter(propertyName);
                     sql = sql.replaceFirst("\?", formatParameterValue(obj));
                 }
             }
         }
     }
     return sql;
 }

 private static String formatParameterValue(Object obj) {
     if (obj == null) {
         return "NULL";
     } else {
         String value = obj.toString();
         if (obj instanceof Date) {
             DateFormat dateFormat = new SimpleDateFormat(PATTERN);
             value = dateFormat.format((Date) obj);
         }
         if (!(obj instanceof Number) && !(obj instanceof Boolean)) {
             value = "'" + value + "'";
         }
         return value;
     }
 }
}

打印的结果如下:

当执行批量save 逻辑的时候,这里会分开打印多条insert SQL。

如果有多个拦截器的时候,需要注意定义顺序。可以像pageHelper中的那样在afterPropertiesSet中手动添加Interceptor

其他内容

Executor:

可以通过配置指定executor-type: simple(默认), 都只在sqlSession会话有效。

BaseExecutor : 抽象类,实现了一级缓存(无法关闭) 。 执行update会清理所有缓存对象。

具体实现:

CachingExecutor: 会处理二级缓存 ,默认关闭(query方法),当update后执行查询会清理缓存。 namespace独享。默认都会使用CachingExecutor进行包装下面的执行器。

  • SimpleExecutor:默认执行器,同一个事务中,重复执行会有缓存
  • ReuseExecutor:会缓存SQL 对应的PreparedStatement。 由于默认有一级缓存的存在,这里貌似一直用不上。
  • BatchExecutor: 用于批量操作优化性能。mybatis-plus 中的saveBatch相关方法会使用该执行器。具体可以查看SqlHelper

缓存:

  • 先查二级缓存(默认关闭,@CacheNamespace: 默认namespace 独有。 @CacheNamespaceRef),PerpetualCache。 MappedStatement#cache
  • 再查一级缓存: sqlSession级别,在创建Executor时生成 PerpetualCache, BaseExecutor#localCache。

ISqlInjector

定义了各种默认CRUD通用的方法,可以继承默认的进行扩展方法

mybatis-plus中内置的DefaultSqlInjector:实现ISqlInjector。

自定义实现 insert duplicate Update

当key不存在时 执行insert、 存在时执行update。

insert into test (id,b, c) value (1,2,2) on duplicate key update b = 2;

参考Insert来实现:

ini 复制代码
public class InsertDuplicateUpdate extends AbstractMethod {
    private static final String name = "insertDuplicateUpdate";
    private static final String sqlScript = "<script>\nINSERT INTO %s %s VALUES %s on duplicate key update %s\n</script>";

    public InsertDuplicateUpdate() {
        super(name);
    }

    @Override
    public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
        KeyGenerator keyGenerator = NoKeyGenerator.INSTANCE;
        SqlMethod sqlMethod = SqlMethod.INSERT_ONE;
        String columnScript = SqlScriptUtils.convertTrim(tableInfo.getAllInsertSqlColumnMaybeIf(null),
                                                         LEFT_BRACKET, RIGHT_BRACKET, null, COMMA);
        String valuesScript = SqlScriptUtils.convertTrim(tableInfo.getAllInsertSqlPropertyMaybeIf(null),
                                                         LEFT_BRACKET, RIGHT_BRACKET, null, COMMA);
        String keyProperty = null;
        String keyColumn = null;
        // 表包含主键处理逻辑,如果不包含主键当普通字段处理
        if (StringUtils.isNotBlank(tableInfo.getKeyProperty())) {
            if (tableInfo.getIdType() == IdType.AUTO) {
                /* 自增主键 */
                keyGenerator = Jdbc3KeyGenerator.INSTANCE;
                keyProperty = tableInfo.getKeyProperty();
                keyColumn = tableInfo.getKeyColumn();
            } else if (null != tableInfo.getKeySequence()) {
                keyGenerator = TableInfoHelper.genKeyGenerator(this.methodName, tableInfo, builderAssistant);
                keyProperty = tableInfo.getKeyProperty();
                keyColumn = tableInfo.getKeyColumn();
            }
        }
        // 前面都是Insert copy来的逻辑
        String sqlSet = SqlScriptUtils.convertTrim(tableInfo.getAllSqlSet(tableInfo.isWithLogicDelete(), null), null, null, null, COMMA);
        String sql = String.format(sqlScript, tableInfo.getTableName(), columnScript, valuesScript, sqlSet);
        SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
        return this.addInsertMappedStatement(mapperClass, modelClass, getMethod(sqlMethod), sqlSource, keyGenerator, keyProperty, keyColumn);
    }
}

创建自定义SQLInjector:

继承DefaultSqlInjector,在原有基础上加入InsertDuplicateUpdate。

scala 复制代码
@Component       // 会自动替换默认的DefaultSqlInjector
public class CustSqlInjector extends DefaultSqlInjector {
    @Override
    public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) {
        List<AbstractMethod> methodList = super.getMethodList(mapperClass, tableInfo);
        methodList.add(new InsertDuplicateUpdate());
        return methodList;
    }
}

Mapper接口:

参考insert 添加方法即可使用

arduino 复制代码
int insertDuplicateUpdate(User entity);

mybatis 核心类:

MybatisMapperProxy:Mapper接口代理对象

SqlSessionTemplate: Mapper接口执行目标方法后会委托到SqlSessionTemplate, 提供了selectXXX方法。selectXXX 最后委托给代理对象执行逻辑。

SqlSessionInterceptor:拦截上面selectXXX执行逻辑。 从DefaultSqlSessionFactory或ThreadLocal获取DefaultSqlSession类。

SpringManagedTransaction:在开启SqlSession 的时候,从TransactionFactory中获取的Transaction(此时并没有获取真正的连接,在执行PrepareStatement的时候才会获取底层连接)。

DefaultSqlSession:包含了Executor(包含Transaction),Configuration

MappedStatement: 对应一个mapper方法的相关定义信息

SystemMetaObject.forObject(): 方便set,get的工具类。 支持属性.属性 进行操作

Reflector: 会缓存类的所有get,set方法

解析生成SQL Script

RawSqlSource: 不需要动态判断的, 没有If 相关的标签,SQL 始终都是一个。

DynamicSqlSource: 每次执行都需要通过条件动态生成最终SQL。


参考文章:

相关推荐
qincloudshaw3 小时前
java中实现对象深克隆的四种方式
后端
代码哈士奇4 小时前
简单使用Nest+Nacos+Kafka实现微服务
后端·微服务·nacos·kafka·nestjs
一 乐4 小时前
商城推荐系统|基于SprinBoot+vue的商城推荐系统(源码+数据库+文档)
前端·数据库·vue.js·spring boot·后端·商城推荐系统
golang学习记4 小时前
VMware 官宣 彻底免费:虚拟化新时代来临!
后端
绝无仅有4 小时前
某短视频大厂的真实面试解析与总结(一)
后端·面试·github
JavaGuide4 小时前
中兴开奖了,拿到了SSP!
后端·面试
绝无仅有4 小时前
腾讯MySQL面试深度解析:索引、事务与高可用实践 (二)
后端·面试·github
IT_陈寒5 小时前
SpringBoot 3.0实战:这套配置让我轻松扛住百万并发,性能提升300%
前端·人工智能·后端
JaguarJack5 小时前
开发者必看的 15 个困惑的 Git 术语(以及它们的真正含义)
后端·php·laravel