1. 项目概述:为什么“常用”二字值得深挖
上次我们聊了SpringBoot整合MyBatis的基础搭建,把架子搭起来了,能跑通一个简单的查询。但说实话,那只是“Hello World”级别。真正进入项目实战,你会发现,日常开发中80%的时间,其实都花在了处理那些“常用”但细节繁多的场景上。比如,怎么优雅地传参、如何高效地写动态SQL、分页到底用哪种方案、事务边界怎么控制……这些才是决定一个项目代码是优雅还是“屎山”的关键。
所以,这篇“(二)常用”,我们就来啃这些硬骨头。我不会再重复配置pom.xml或者application.yml这种基础操作,而是直接切入实战中最高频、最容易出问题的环节。目标很明确:让你看完之后,手里的SpringBoot+MyBatis项目代码质量能立刻上一个台阶,同时避开那些我踩过的、以及安全扫描(比如奇安信)经常报的坑。无论你是刚接手一个老项目,还是正在从零搭建新系统,这里面的内容都是你马上就能用上的“弹药”。
2. 核心细节解析与实操要点
2.1 参数传递的“道”与“术”:告别混乱
MyBatis的参数传递看似简单,但用不对就是Bug之源。核心原则就一个:明确类型,使用命名参数。
1. 简单类型与@Param注解当你DAO层接口的方法只有一个参数时,在XML中可以直接用#{参数名}引用。但这里有个巨坑:如果这个参数是基本类型(int, long)或String,MyBatis允许你随便起名,比如#{id}、#{value}甚至#{aaa}都能工作。这导致了极大的不一致性。我的强制规范是:即使只有一个参数,也强烈建议使用@Param注解明确命名。
// 不推荐:可读性差,容易混淆 User selectById(Long id); // XML中必须用 #{id},但容易记错 // 强烈推荐:清晰明确 User selectById(@Param(“userId”) Long id); // XML中使用 #{userId},一目了然2. Map与POJO传参的抉择多个参数时,无非两种方式:用一个Map装起来,或者用一个POJO(实体类或DTO)对象。
- Map传参:灵活,适合参数不固定、临时查询的场景。但缺点非常明显:类型不安全,键名容易拼写错误(运行时才报错),可读性极差。除非是极端动态的场景,否则我基本不用。
- POJO传参:这是主流和推荐的方式。将查询条件封装成一个专门的
QueryDTO,属性名就是参数名。这不仅安全,而且语义清晰,后期维护方便。
// 推荐:使用DTO传参 @Data // 使用Lombok public class UserQueryDTO { private String username; private Integer status; private Date createTimeStart; private Date createTimeEnd; } List<User> selectByCondition(UserQueryDTO query);在XML中,直接使用#{username},#{status}即可,MyBatis会自动从DTO对象中获取对应属性的值。
3. 复杂参数:List、Array的传递查询id in (?)的场景太常见了。传List或数组时,关键在于XML中如何使用。
List<User> selectByIds(@Param(“idList”) List<Long> ids);对应的XML写法,强烈推荐使用<foreach>标签,绝对不要用字符串拼接。
<select id=“selectByIds” resultType=“User”> SELECT * FROM user WHERE id IN <foreach collection=“idList” item=“id” open=“(” separator=“,” close=“)”> #{id} </foreach> </select>这里collection的值就是@Param注解里指定的“idList”。如果你不用@Param,传了一个单独的List参数,那么这里collection必须写“list”(这是MyBatis的默认别名),可读性很差,所以再次证明@Param的重要性。
2.2 动态SQL:灵活与安全的平衡艺术
动态SQL是MyBatis的精华,也是SQL注入漏洞的重灾区。核心就那几个标签:<if>,<choose>,<where>,<set>,<foreach>。
1.<where>和<set>标签的魔法这两个标签能帮你自动处理WHERE和SET子句前的AND/OR或者逗号,避免语法错误。
<select id=“selectByCondition” resultType=“User”> SELECT * FROM user <where> <if test=“username != null and username != ‘’“> AND username LIKE CONCAT(‘%’, #{username}, ‘%’) </if> <if test=“status != null”> AND status = #{status} </if> </where> </select>注意<where>标签内的<if>条件,我依然以AND开头。<where>标签会智能地去除第一个多余的AND或OR。<set>标签同理,用于更新操作。
2. 终极安全红线:#{}与${}的区别这是面试必问,更是安全扫描的核心关注点。一句话:99%的场景用#{},${}要慎之又慎。
#{}:是预编译参数占位符。MyBatis会将其替换为?,然后通过PreparedStatement设置参数。可以防止SQL注入,是绝对安全的。${}:是字符串替换。MyBatis会直接将参数值替换到SQL语句中。存在SQL注入风险。
那${}什么时候用?极少数动态表名、列名的场景。比如,按不同月份查询分表order_202401,order_202402。
<select id=“selectFromDynamicTable”> SELECT * FROM ${tableName} WHERE id = #{id} <!-- 参数部分依然用#{} --> </select>重要警告:使用
${}时,参数值绝对不能来自用户直接输入(如前端表单)。必须是在服务端内部可控、枚举或严格校验过的值。奇安信等安全扫描工具一旦发现${}中有用户可控参数,必定会报SQL注入漏洞。
3.<choose>标签处理多分支逻辑类似于Java的switch-case,适合“多选一”的场景。
<select id=“selectComplex”> SELECT * FROM task <where> <choose> <when test=“type == ‘urgent’”> AND priority = 1 AND status = 0 </when> <when test=“type == ‘normal’”> AND priority = 3 </when> <otherwise> AND status = 1 </otherwise> </choose> </where> </select>2.3 结果映射:解决“名不对姓”的烦恼
数据库字段user_name,Java实体属性userName,这种不一致太常见了。有几种解决方案:
1. 起别名(最直接,但繁琐)在SQL中写SELECT user_name AS userName。
2. 开启驼峰命名自动映射(推荐)在application.yml中配置,这是SpringBoot整合后最方便的方式。
mybatis: configuration: map-underscore-to-camel-case: true # 开启下划线转驼峰开启后,user_name会自动映射到userName。但要注意,如果数据库字段就是username(驼峰),而实体是userName,这个配置不会生效,因为它是“下划线转驼峰”,不是“大小写转换”。
3. 使用<resultMap>进行自定义映射(最强大)对于复杂查询,比如关联查询、字段类型转换,<resultMap>是终极武器。
<resultMap id=“DetailedUserMap” type=“User”> <id property=“id” column=“id”/> <result property=“userName” column=“user_name”/> <result property=“createTime” column=“create_time”/> <!-- 一对一关联 --> <association property=“department” javaType=“Department”> <id property=“deptId” column=“dept_id”/> <result property=“deptName” column=“dept_name”/> </association> <!-- 一对多关联 --> <collection property=“roles” ofType=“Role”> <id property=“roleId” column=“role_id”/> <result property=“roleName” column=“role_name”/> </collection> </resultMap>使用<resultMap>后,你的<select>标签的resultType就要换成resultMap,指向这个映射的id。虽然写起来稍多,但结构清晰,尤其适合作为复杂查询的公共映射定义。
3. 实操过程与核心环节实现
3.1 分页查询:从PageHelper到MyBatis-Plus的选择
分页是高频需求。在SpringBoot生态里,主要有两种主流选择。
方案一:使用PageHelper(传统、简单)PageHelper是国内开发者最熟悉的插件,原理是使用MyBatis的拦截器,在SQL执行前自动拼接LIMIT语句。
- 引入依赖:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>最新版本</version> </dependency> - 使用方式:
踩坑提醒:@Service public class UserServiceImpl { public PageInfo<User> getUsers(int pageNum, int pageSize) { // 关键!这行代码必须紧跟在执行查询的代码之前 PageHelper.startPage(pageNum, pageSize); // 接下来执行你的查询方法,这个方法里就是普通的查询SQL,不需要写LIMIT List<User> userList = userMapper.selectByExample(new Example()); // 用PageInfo包装结果,里面包含了分页信息(总条数、总页数等) return new PageInfo<>(userList); } }PageHelper.startPage(pageNum, pageSize)必须紧贴执行SQL的Mapper方法调用之前,中间不能有其它数据库查询操作,否则分页会失效或错乱。这是一个非常容易出错的地方。
方案二:使用MyBatis-Plus的内置分页(现代、集成度高)如果你已经使用了MyBatis-Plus(MP),那么它的分页功能更优雅、更安全。
- 配置分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据数据库类型调整 return interceptor; } } - 使用方式:
MP的分页将分页参数和查询逻辑分离,避免了PageHelper的“紧贴”陷阱,代码更清晰。个人建议:新项目直接上MyBatis-Plus,它的分页、条件构造器等功能能极大提升开发效率。@Service public class UserServiceImpl { @Autowired private UserMapper userMapper; // 假设继承自BaseMapper public IPage<User> getUsers(int pageNum, int pageSize) { // 1. 创建分页对象 Page<User> page = new Page<>(pageNum, pageSize); // 2. 创建查询条件(可选) LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(User::getName, “张”); // 3. 执行分页查询 return userMapper.selectPage(page, wrapper); // 返回的page对象本身就包含了数据列表和所有分页信息 } }
3.2 事务管理:让数据操作要么全成功,要么全失败
SpringBoot中通过@Transactional注解声明事务,简单到令人发指,但细节决定成败。
1. 基本使用在Service层的方法上添加注解即可。
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) // 关键:指定回滚的异常类型 public void createOrder(Order order) { // 1. 保存订单主表 orderMapper.insert(order); // 2. 扣减库存(可能失败) inventoryService.deduct(order.getSkuId(), order.getQuantity()); // 3. 生成日志 logService.record(order); // 如果第二步或第三步抛出异常,第一步的插入也会回滚 } }2. 关键参数与避坑指南
rollbackFor = Exception.class:务必加上。默认情况下,@Transactional只在抛出运行时异常(RuntimeException)和Error时回滚,受检异常(Exception)不会触发回滚。加上这个属性,让所有异常都触发回滚,更符合业务直觉。- 事务失效的常见场景:
- 方法非public:
@Transactional只能用于public方法。 - 自调用问题:在同一个类中,一个非事务方法A调用另一个有
@Transactional注解的方法B,事务是不会生效的。因为事务是基于AOP代理实现的,自调用不走代理。 - 异常被捕获:如果在方法内部用
try-catch吞掉了异常,事务管理器感知不到异常,自然不会回滚。 - 数据库引擎不支持:MySQL的MyISAM引擎不支持事务,必须使用InnoDB。
- 方法非public:
3. 多数据源事务如果你的项目配置了多个数据源,那么默认的@Transactional可能无法管理跨库事务。这是一个复杂话题,通常需要引入分布式事务解决方案(如Seata),或者从设计上避免跨库的写操作。对于大多数应用,确保一个事务方法内的所有数据库操作都在同一个数据源上是最佳实践。
3.3 高级特性:类型处理器与插件开发
1. 类型处理器(TypeHandler)用于处理Java类型和JDBC类型之间的特殊转换。比如,把Java的List<String>以JSON字符串格式存入数据库的varchar字段。
- 自定义TypeHandler:
@MappedJdbcTypes(JdbcType.VARCHAR) @MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandler<List<String>> { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, objectMapper.writeValueAsString(parameter)); } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { String json = rs.getString(columnName); return json == null ? null : objectMapper.readValue(json, new TypeReference<List<String>>(){}); } // ... 其他getNullableResult重载 } - 注册并使用: 可以在配置文件中全局注册,也可以在具体的
<result>映射中指定。
或者在XML中:mybatis: type-handlers-package: com.yourpackage.handler<result column=“tags” property=“tagList” typeHandler=“com.yourpackage.handler.StringListTypeHandler”/>
2. 插件开发(Interceptor)MyBatis插件功能强大,可以拦截四大核心对象:Executor,ParameterHandler,ResultSetHandler,StatementHandler。常用场景:分页、数据权限过滤、SQL执行时间监控、通用字段自动填充(如create_time,update_time)。 下面是一个简单的SQL执行时间监控插件示例:
@Intercepts({ @Signature(type = StatementHandler.class, method = “query”, args = {Statement.class, ResultHandler.class}), @Signature(type = StatementHandler.class, method = “update”, args = {Statement.class}) }) @Component @Slf4j public class SqlCostInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { long startTime = System.currentTimeMillis(); try { return invocation.proceed(); // 执行原方法 } finally { long cost = System.currentTimeMillis() - startTime; StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String sql = boundSql.getSql(); if (cost > 1000) { // 超过1秒的SQL记录为慢查询 log.warn(“慢SQL执行耗时:{} ms, SQL: {}”, cost, sql); } else { log.debug(“SQL执行耗时:{} ms”, cost); } } } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }注意:插件会拦截所有SQL,生产环境要谨慎使用,避免性能开销和日志泛滥。通常用于开发调试阶段。
4. 常见问题与排查技巧实录
4.1 SQL注入漏洞排查与修复
这是安全扫描(如奇安信)的重点关照对象。除了前面强调的禁止在${}中使用用户输入外,还有以下隐蔽场景:
1. Like查询的注入风险错误的写法:
<if test=“name != null”> AND name LIKE ‘%${name}%’ <!-- 高危!直接拼接 --> </if>正确的写法:
<if test=“name != null”> AND name LIKE CONCAT(‘%’, #{name}, ‘%’) <!-- 使用#{}和CONCAT函数 --> </if>或者在Java代码中拼接好再传参:
String nameParam = “%” + name + “%”; query.setName(nameParam);AND name LIKE #{name} <!-- XML中直接使用 -->2. In查询的动态参数个数有时in语句的参数列表是动态生成的,数量不定。务必使用<foreach>标签配合#{},切勿拼接字符串。
<!-- 安全 --> WHERE id IN <foreach collection=“ids” item=“id” open=“(” separator=“,” close=“)”> #{id} </foreach> <!-- 危险! --> WHERE id IN (${idListStr})3. Order By 动态排序按用户选择字段排序是一个常见需求,但字段名不能使用#{}(会被加上引号,导致语法错误),又不能用${}直接接用户输入。解决方案:在服务端进行校验和映射。
// 前端传 “name_asc” 或 “createTime_desc” String sortField = getSortField(request.getSort()); // 通过一个安全映射方法获取 // getSortField 方法实现示例 private String getSortField(String input) { Map<String, String> safeMap = new HashMap<>(); safeMap.put(“name_asc”, “name ASC”); safeMap.put(“createTime_desc”, “create_time DESC”); // 只返回预设的安全值,否则返回默认排序 return safeMap.getOrDefault(input, “id DESC”); }然后在XML中使用${safeSortField}。因为safeSortField的值是服务端完全可控的,所以是安全的。
4.2 映射失败与N+1查询问题
1. 属性映射为null
- 检查点1:数据库字段名和实体属性名是否匹配?是否开启了
map-underscore-to-camel-case? - 检查点2:查询SQL中是否包含了该字段?有时手写SQL漏了字段。
- 检查点3:实体类属性是否有正确的getter/setter方法?如果使用Lombok的
@Data,检查编译后的class文件是否生成了对应方法。
2. 经典的N+1查询问题在关联查询(一对多)时,如果先在主查询中查出N条订单,然后循环每条订单再去查询其关联的订单项,就会产生1(主查询)+ N(循环查询)次数据库请求,性能极差。解决方案:使用MyBatis的关联查询(<collection>)一次性查出所有数据。
<resultMap id=“orderWithItemsMap” type=“Order”> <id property=“id” column=“order_id”/> <collection property=“itemList” ofType=“OrderItem”> <id property=“id” column=“item_id”/> <result property=“productName” column=“item_product_name”/> <!-- 注意:关联查询时,所有column必须唯一,通常通过起别名解决 --> </collection> </resultMap> <select id=“selectOrderWithItems” resultMap=“orderWithItemsMap”> SELECT o.id as order_id, i.id as item_id, i.product_name as item_product_name FROM order o LEFT JOIN order_item i ON o.id = i.order_id WHERE o.id = #{id} </select>这样一次查询就能拿到订单及其所有明细,避免了N+1问题。
4.3 性能调优与日志排查
1. 开启MyBatis SQL日志在开发环境,开启日志对于调试和性能分析至关重要。
logging: level: com.yourpackage.mapper: debug # 将你的Mapper接口所在包级别设为debug或者更精确地只打印SQL:
logging: level: org.apache.ibatis: info com.apache.ibatis.jdbc: debug这样可以在控制台看到执行的SQL语句和参数,方便检查SQL是否正确、是否有全表扫描等。
2. 识别慢查询与全表扫描
- 通过上面提到的自定义插件监控SQL耗时。
- 结合数据库的慢查询日志(如MySQL的
slow_query_log)进行分析。 - 检查关键查询是否使用了索引。对于复杂的动态查询,要特别注意
<if>条件可能导致索引失效的情况。例如,对某个字段进行NULL判断或函数操作(WHERE UPPER(name) = …)会使该字段上的索引失效。
3. 批量操作提升性能对于大量数据插入或更新,使用MyBatis的批量操作能极大提升性能。
@Transactional public void batchInsert(List<User> userList) { // 方式一:使用MyBatis的foreach标签(适用于数据量不是特别大,如几千条) // 在XML中写INSERT INTO user (...) VALUES <foreach>...</foreach> // 方式二:使用ExecutorType.BATCH(更高效,适合大数据量) SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH); UserMapper batchMapper = sqlSession.getMapper(UserMapper.class); try { for (User user : userList) { batchMapper.insert(user); } sqlSession.commit(); // 批量提交 } finally { sqlSession.close(); } }注意:批量操作时,单次提交的数据量不宜过大(通常建议1000-5000条一批),避免内存溢出和数据库事务锁持有时间过长。
4.4 与其它框架整合时的配置冲突
1. 与Flowable工作流引擎整合如热词中提到的“flowable-ui覆盖了mybatis的配置”,这是因为Flowable自带了自己的MyBatis配置和SessionFactory。在同一个SpringBoot项目中,如果你既有业务MyBatis,又引入了Flowable,需要小心配置冲突。解决方案:明确区分两个数据源和两个MyBatis配置。通常业务数据源和Flowable的流程引擎数据源是分开的。你需要为业务MyBatis配置指定明确的SqlSessionFactoryBean和MapperScannerConfigurer,并限定其扫描的包路径,避免扫描到Flowable的Mapper接口。
2. 多数据源配置当项目需要连接多个数据库时,每个数据源都需要自己独立的DataSource、SqlSessionFactory和TransactionManager。配置的关键在于使用@Primary注解指定一个主数据源,并为其他数据源的Bean使用@Qualifier进行区分。同时,在Service层使用@Transactional注解时,如果需要指定非主数据源的事务管理器,需要使用@Transactional(value = “otherTransactionManager”)。
3. 配置文件优先级与覆盖SpringBoot中,MyBatis的配置参数(如mybatis.configuration.map-underscore-to-camel-case)可能会被其他方式覆盖。例如,如果你在代码中显式创建了一个SqlSessionFactoryBean并设置了Configuration属性,那么配置文件中的同名设置可能会失效。排查配置问题时,要检查所有可能设置该值的地方。
5. 工程化实践:超越CRUD的代码组织
当项目规模变大,Mapper和XML文件会急剧膨胀。好的组织方式能让你事半功倍。
1. Mapper接口与XML的分离与约定
- 位置约定:通常将Mapper接口放在
com.xxx.mapper包下,对应的XML文件放在resources/mapper目录或resources/com/xxx/mapper目录下。 - 命名约定:Mapper接口名
UserMapper.java,对应的XML文件名为UserMapper.xml。这是MyBatis的默认查找规则。 - 使用
@MapperScan:在启动类或配置类上使用@MapperScan(“com.xxx.mapper”),避免在每个Mapper接口上写@Mapper注解。
2. 使用MyBatis-Plus进一步提效(强烈推荐)MyBatis-Plus(MP)在MyBatis基础上做了大量增强,能极大减少样板代码。
- 通用CRUD:Mapper接口继承
BaseMapper<T>,即可获得insert,selectById,update,delete等一系列方法,无需写XML。 - 条件构造器:使用
QueryWrapper或LambdaQueryWrapper,可以用Java代码流畅地构建查询条件,避免在XML中写大量<if>标签,类型安全,编译期就能发现问题。LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1) .like(User::getName, “王”) .between(User::getCreateTime, startDate, endDate) .orderByDesc(User::getId); List<User> list = userMapper.selectList(wrapper); - 代码生成器:MP提供的代码生成器,可以一键生成Entity, Mapper, XML, Service, Controller层代码,是快速启动新项目的利器。
3. 自定义通用Mapper与Service即使不使用MP,也可以抽象出通用的方法。
- 定义通用Mapper接口:
public interface BaseMapper<T, PK> { T selectById(PK id); int insert(T entity); int updateById(T entity); int deleteById(PK id); // ... 其他通用方法 } - 使用泛型实现:然后让你的业务Mapper,如
UserMapper,继承这个BaseMapper<User, Long>。具体的SQL实现可以通过一个通用的XML模板,或者利用MyBatis的Provider注解(如@SelectProvider)动态生成SQL。这需要一定的MyBatis高级知识,但能带来极大的复用性。
4. 单元测试与集成测试为Mapper层编写测试至关重要。SpringBoot Test可以很方便地集成。
@SpringBootTest @Transactional // 测试后自动回滚,不污染数据库 @Rollback class UserMapperTest { @Autowired private UserMapper userMapper; @Test void testSelectById() { User user = userMapper.selectById(1L); assertNotNull(user); assertEquals(“张三”, user.getUsername()); } }使用内存数据库(如H2)进行测试,可以保证测试环境独立、快速。
6. 总结与个人工具箱分享
走完这一趟,你会发现SpringBoot整合MyBatis的“常用”部分,远不止是配置和基础CRUD。它涉及了安全、性能、可维护性、团队协作等多个维度。我的个人体会是,在中小型项目中,直接采用“SpringBoot + MyBatis-Plus”的组合是性价比最高的选择,它能帮你规避掉很多原生MyBatis的繁琐和坑,尤其是条件构造器和分页插件。
对于大型复杂项目,可能需要更精细的控制,那么深入理解原生MyBatis的插件机制、自定义类型处理器、以及多数据源/事务管理就变得必不可少。无论哪种选择,有几条原则是共通的:
- 安全第一:永远对用户输入保持警惕,
${}的使用要经过严格评审。 - 明确约定:团队内对参数传递、结果映射、文件组织方式要有明确的规范。
- 日志驱动:开发环境打开SQL日志,它是你调试和性能分析的第一手资料。
- 测试覆盖:为复杂的动态SQL和业务逻辑编写足够的Mapper层测试。
最后,分享一个我自己的“踩坑检查清单”,在代码Review或上线前可以快速过一遍:
- [ ] 所有
${}的使用,参数是否服务端完全可控?(防注入) - [ ] 动态SQL中的
<if>条件,是否考虑了字段为null、空字符串、集合为空等多种边界情况? - [ ] Like查询是否使用了
CONCAT或代码拼接,避免了${}? - [ ] 分页查询是否使用了推荐插件(PageHelper或MP),并注意了使用姿势?
- [ ]
@Transactional注解是否添加了rollbackFor = Exception.class? - [ ] 关联查询是否避免了N+1问题?(检查是否有循环内查数据库)
- [ ] 复杂的
<resultMap>,其column别名是否唯一,防止映射错乱? - [ ] 批量操作是否考虑了批处理大小和事务提交时机?
把这些点都做到位,你的SpringBoot+MyBatis项目就不仅“能用”,而且“健壮”、“高效”、“好维护”了。技术栈是死的,但如何用好它,才是体现工程师价值的地方。