☰
学MyBatis前必须搞懂的JDBC、ORM与核心机制
2026/11/3 17:09:40 网站建设 项目流程

带过几批新人之后我发现一个规律:凡是直接抱着 MyBatis 的 XML 模板开抄的人,头一个月基本都在同一个坑里打转——SQL 跑通了,但说不清为什么能跑通,等遇到参数拿不到、结果映射错位、事务不生效的时候,就彻底卡死。这篇东西写给准备学 MyBatis 但还没动手的人,也写给那些已经写了半年 CRUD 却总觉得心里没底的人。我把学 MyBatis 之前必须搞明白的东西摊开来聊:JDBC 到底留下了什么烂摊子、ORM 在映射什么、SqlSessionFactory 和 SqlSession 各自的生命周期、#{}和${}的底层差别、resultMap 的自动映射规则、动态 SQL 的拼装逻辑、两级缓存分别在什么条件下才真正生效。这些不是 API 文档里抄来的条目,是我自己在项目里被咬过之后总结出来的顺序。先知道边界,再学用法,这是我反复验证过的学习曲线——顺序反了,学得越快忘得越快。

1. 先搞清楚 JDBC 留下了什么烂摊子

1.1 一段原生 JDBC 代码里的六处重复劳动

很多人学 MyBatis 的第一个错误,是把它当成"一个能少写代码的库"。这个认知太浅,导致后面所有设计都理解偏。要理解 MyBatis 存在的理由,最直接的办法是把一段手写 JDBC 摆出来,逐行看清楚哪些劳动是重复的、哪些是框架该接手的。

public List<User> findAdult() { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<User> list = new ArrayList<>(); try { conn = DriverManager.getConnection(url, user, pwd); ps = conn.prepareStatement("select id, name, age from t_user where age > ?"); ps.setInt(1, 18); rs = ps.executeQuery(); while (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setName(rs.getString("name")); u.setAge(rs.getInt("age")); list.add(u); } } catch (SQLException e) { throw new RuntimeException(e); } finally { if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} } return list; }

这段代码里藏着六处重复劳动,每一处都在消耗你的注意力,而它们和业务逻辑一点关系都没有。

第一是连接管理,包括获取、关闭、异常情况下的兜底关闭,还有一堆判空。第二是 SQL 硬编码在 Java 字符串里,改一个字段就得重新编译,DBA 审 SQL 的时候要在 class 文件里找。第三是参数绑定,位置索引从 1 开始,setInt(1, 18)里的 1 是个魔法数字,多一个参数就得重新数位置。第四是结果集到对象的手工映射,字段名、类型、下标全靠手工对齐,rs.getLong("id")里字符串写错一个字母运行期才炸。第五是异常转换,SQLException是受检异常,每个方法都得包一层,最后全变成RuntimeException,调用栈面目全非。第六是资源释放的样板代码,三个 try-catch 嵌套,看着就烦。

这六处劳动,在任何一个有十个以上 DAO 方法的项目里都会被复制十遍以上。框架存在的意义就是把这部分"每次都一样"的东西抽走,让你只写"每次都不一样"的部分——SQL 和映射关系。MyBatis 的取舍很清晰:前面六条它全包,但 SQL 它不替你写。这个选择决定了后面所有的设计风格,也决定了它和 JPA 那一派的分道扬镳。

1.2 ORM 到底在"映射"什么

理解 ORM 之前,得先承认一件事:对象模型和关系模型天生不对付,这个矛盾有个专门的说法叫阻抗不匹配。它具体表现在四个地方,我用生活化的说法解释一遍。

粒度不匹配:数据库里一张t_order表和一个t_order_item表,在 Java 里可能是Order里挂一个List<OrderItem>。表是平的,对象是嵌套的。类型不匹配:数据库有decimal、timestamp、tinyint这些类型,Java 只有BigDecimal、Date、Integer,中间隔着转换规则。关联不匹配:表之间靠外键连,对象之间靠引用连,外键是值,引用是地址。继承不匹配:Java 有继承和多态,关系数据库没有,只能靠单表加类型字段或者多表加连接来模拟。

ORM 干的事,就是在这两种世界观之间架一座桥。但桥的自动化程度可以差很远。全自动的那一派(JPA/Hibernate)帮你生成 SQL,你写findByAgeGreaterThan(18)它就翻译成 SQL,好处是开发快,坏处是复杂查询生成的 SQL 常常不是你想要的那个执行计划,优化的时候你得跟框架搏斗。半自动的那一派(MyBatis)只接管参数绑定和结果映射,SQL 你自己写,好处是每一行 SQL 都在掌控之内,坏处是你得会写 SQL,而且要写不少。

我在实际项目里选型的判断标准很简单:业务查询超过一半是简单单表操作、团队里没人愿意深究执行计划,选全自动;查询涉及多表关联、分页、报表、复杂条件拼装、需要 DBA 参与审核,选半自动。这不是技术优劣,是协作模式决定的。你在学 MyBatis 之前先想清楚这个前提,就不会在学到一半的时候冒出一个念头——“这东西怎么什么都要我自己写”,因为它本来就这么设计的。

1.3 MyBatis 在持久层栈里的真实位置

还有个特别容易混淆的点:很多人以为 MyBatis 管连接池、管事务、管一切。不是的。把持久层拆成几层看,位置一目了然。

最底下是 JDBC 规范和各数据库的驱动,这一层提供Connection、PreparedStatement、ResultSet这些最原始的接口。往上一层是数据源,负责连接池的创建、借用、归还、保活,常见的就是各类连接池实现,MyBatis 只定义了一个DataSource接口,具体实现交出去。再往上是 MyBatis 自己,它负责 SQL 的解析、参数的绑定、语句的执行编排、结果集的映射、缓存的维护。最上面是事务,MyBatis 有一个简单的Transaction抽象,但在实际工程里基本都交给外部的声明式事务去管。

这个分层意味着三件事。第一,你配连接池参数的时候,调的其实是数据源,不是 MyBatis。第二,事务边界不在 MyBatis 的 Mapper 方法上,而在更外层的服务方法上,所以你在 Mapper 里写两个 update 想让它原子生效是不现实的。第三,MyBatis 的核心价值区就在那个"SQL 解析 + 参数绑定 + 结果映射"的窄带里,搞清楚这条窄带里的机制,等于掌握了它八成的复杂度。

2. 动手之前先嚼碎的几个核心概念

2.1 SqlSessionFactory 是重量级单例,SqlSession 是轻量级一次性

这两个名字长得像,生命周期却完全相反,这是新手第一个必踩的坑。我用一个类比:SqlSessionFactory像一家工厂,建起来成本高,建好之后长期存在,全应用共用一个就行;SqlSession像工厂开出的一张工单,用完就作废,不能攒着下次接着用。

SqlSessionFactory的构建过程要解析全局配置文件、解析所有 Mapper 映射文件、注册语句、建立配置对象,这一套下来开销不小。所以标准做法是在应用启动时构建一次,存成单例。我看到过有人在每个方法里new SqlSessionFactoryBuilder().build(...),压测的时候 QPS 上不去,排查半天发现全耗在重复解析 XML 上。这个错误很低级,但犯的人不少,因为单元测试里这么写没问题,跑得快看不出来。

SqlSession则完全相反,它内部持有一个数据库连接(准确地说是从数据源借来的),代表一次会话。它不是线程安全的,绝对不能塞到静态字段或者成员变量里共用。正确的用法是每次操作开一个,用完在 finally 里关掉,或者干脆交给上层框架管理。

// 启动阶段,只做一次 SqlSessionFactory factory = new SqlSessionFactoryBuilder() .build(Resources.getResourceAsStream("mybatis-config.xml")); // 每次操作,开一个关一个 try (SqlSession session = factory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User u = mapper.selectById(1L); // 注意:这里没 commit,查询无所谓,写操作必须 commit }

这里还有个隐蔽的坑:openSession()默认不自动提交。你执行了一个 insert,代码跑完没报错,去数据库一看没数据,原因就是忘了commit()。要么openSession(true)开自动提交,要么在写完显式提交。我个人的习惯是写操作显式提交,因为自动提交会掩盖"多条语句应该在一个事务里"这个事实,等你哪天需要保证原子性的时候,改起来要动一大片。

2.2 Mapper 接口没有实现类,靠什么跑起来

第一次看到这种代码的人都会愣一下:UserMapper明明是个接口,一个实现类都没写,session.getMapper(UserMapper.class)返回的却是个能用的对象,方法还能正确执行对应的 SQL。这中间发生了什么?

答案是 JDK 动态代理。getMapper内部用MapperProxyFactory创建了一个代理对象,这个代理实现了你的接口。当你调用接口方法时,请求被MapperProxy拦截,它做三件事:根据接口全限定名加方法名拼出一个语句 ID(默认是com.xxx.UserMapper.selectById这种形式),去配置里找对应的MappedStatement,然后交给SqlSession去执行。

理解了这条链路,很多报错就变得可以解释了。比如经典的这个:

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.demo.mapper.UserMapper.selectById

这个报错的意思是:代理收到了方法调用,拼出了语句 ID,但在配置里找不到。排查方向立刻清晰了——要么 XML 里的namespace和接口全限定名不一致,要么<select>的id和方法名对不上,要么 XML 压根没被扫描到(放在resources下但目录结构不对,Maven 默认只打包resources下的资源,接口和 XML 同包分离时最容易出这个问题)。

顺便说一个工程实践:接口名和 XML 文件名保持一致、放在同名包路径下、namespace写全限定名,这三条对齐了,90% 的绑定问题不会发生。我见过有人为了"整齐"把十个 Mapper 的 XML 全塞进一个mapper目录,namespace靠手工维护,后来加了一个新模块忘了改,找了两个小时。约定大于配置这句话,在 MyBatis 里体现得特别明显。

2.3 配置文件分两层:全局配置与 Mapper 映射

MyBatis 的配置有两套,新手经常搞混它们各自管什么。

全局配置(通常叫mybatis-config.xml)管的是"整个框架怎么运行":环境配置(数据源、事务管理器)、类型别名、类型处理器、插件、全局开关(比如下划线转驼峰、缓存开关)、Mapper 注册。它是一份,应用启动时加载一次。Mapper 映射文件管的是"每条 SQL 长什么样":语句本身、参数类型、结果映射、动态 SQL 标签。它有很多份,一个接口一份。

两者通过namespace建立联系,这个联系点是全局唯一的语句 ID。所以两个 Mapper 文件里不能出现相同的namespace + id组合,否则后加载的会覆盖先加载的——注意是静默覆盖,不报错。这个特性坑过不少人:复制粘贴一个 Mapper 文件改新功能,忘了改namespace,结果老功能莫名其妙跑了新 SQL。

全局配置里有两个参数我建议一开始就配好,能省掉后面大量低级错误:

<settings> <!-- 数据库下划线命名自动映射到 Java 驼峰属性 --> <setting name="mapUnderscoreToCamelCase" value="true"/> <!-- 查询结果为 null 的列也调用 setter,避免包装类型保持默认值造成误判 --> <setting name="callSettersOnNulls" value="true"/> </settings>

第一条几乎是必开的,user_name到userName的映射不用再手写一遍。第二条看情况,开了之后 null 值也会走 setter,好处是对象状态和数据库严格一致,坏处是某些场景下会把默认值覆盖成 null,用之前想清楚你的业务是不是依赖"数据库没值就用 Java 默认值"这个行为。

3. 参数传递与结果映射:翻车率最高的两个环节

3.1#{}预编译占位与${}字符串拼接的本质差别

这两个符号长得像,底层走的是两条完全不同的路,搞不清楚会同时踩上安全和性能两个坑。

#{}走的是PreparedStatement的占位符,最终 SQL 里是一个?,参数通过setXxx绑定进去。数据库会先编译这个带占位符的语句,生成执行计划,然后再把参数填进去。这意味着参数永远被当成"值"处理,不会改变 SQL 的结构,天然免疫注入。同时也意味着同一条 SQL 换不同参数执行,可以复用执行计划,性能更好。

${}走的是字符串替换,在 SQL 发出去之前就把值拼进了语句文本里。它拼的是 SQL 结构的一部分,所以能用的场景只有那些"必须改变 SQL 结构"的地方,比如动态表名、动态排序字段、动态列名。

<!-- 正确:值用 #{} --> <select id="findByName" resultType="User"> select * from t_user where name = #{name} </select> <!-- 错误示范:值用 ${},等着被注入 --> <select id="findByNameBad" resultType="User"> select * from t_user where name = '${name}' </select> <!-- 合理用法:排序字段本来就是结构的一部分 --> <select id="listByOrder" resultType="User"> select * from t_user order by ${orderColumn} ${orderDirection} </select>

最后这个例子虽然合理,但不代表安全。如果orderColumn来自前端参数,攻击者可以传一个构造过的字符串把语句改掉。正确做法是在 Java 侧做白名单校验,只允许有限几个字段名通过,非法值直接拒绝。这一条我在代码评审里必查,见过太多"排序参数是从前端透传的"这种事。

还有一个#{}的细节值得提前说:#{}里可以写jdbcType,比如#{name,jdbcType=VARCHAR}。当参数可能为 null 且数据库列不允许 null 时,某些驱动会报"无效的列类型",加上jdbcType能解决。这个坑在 Oracle 上特别常见,提前知道能省一次深夜排查。

3.2 单参数、多参数、对象参数、集合参数的传参规则

MyBatis 的参数处理有一套自己的规则,摸清楚之后你会发现它其实很讲道理,只是规则没有集中写在一页纸上。

单参数时,#{}里的名字随便写,写什么都能取到值。因为只有一个参数,框架直接拿它填进去。所以selectById(Long id)对应的 XML 写#{id}、#{value}、#{abc}都能跑。这反而害人——有人养成了乱写的习惯,等到方法变成两个参数,立刻报"找不到参数"。

多参数时,规则变了。不加注解的情况下,参数在内部被包装成一个 Map,key 是arg0、arg1或者param1、param2。XML 里必须用这些名字。这种写法可读性极差,正确做法是加@Param注解:

List<User> search(@Param("keyword") String keyword, @Param("minAge") Integer minAge);
<select id="search" resultType="User"> select * from t_user where name like concat('%', #{keyword}, '%') and age &gt;= #{minAge} </select>

对象参数时,直接写属性名,#{userName}会调用对象的getUserName()。嵌套对象用点号,#{address.city}。集合参数要配合<foreach>,这个在下一节讲。

还一个容易忽略的点:parameterType这个属性在新版本里基本可以省略,框架会自动推断。但省略之后有个副作用,某些复杂场景推断不出来会走UnknownTypeHandler,行为可能和预期不一致。我现在的习惯是简单类型不写,复杂自定义对象写上,图个确定性。

3.3 resultMap 与自动映射的优先级和开关

结果映射这块,最需要提前建立的概念是"自动映射和显式映射的优先级关系"。

自动映射的意思是:查出来的列名和对象的属性名对上了,框架自动填值。对上指的是完全一致,或者开启了下划线转驼峰之后按转换规则一致。默认行为是PARTIAL,意思是自动映射所有没在 resultMap 里显式声明过的列。NONE关掉自动映射,FULL在嵌套结果映射时也做自动映射。

这个默认值PARTIAL的设计很有讲究。它避免了FULL在嵌套关联查询时把内层对象的属性也自动填掉、导致数据串味的问题。理解这一点的实际价值在于:当你的嵌套映射出现"外层对象属性被内层结果覆盖"的怪现象时,你会立刻想到是自动映射层级的问题,而不是怀疑人生。

显式 resultMap 的使用场景,我总结成三类:列名和属性名差异太大(比如dept_no映射到departmentNumber),需要处理复杂类型(关联对象、集合),需要复用映射配置(<resultMap>的extends和<association>的resultMap引用)。

这里有个性能上的坑要提前说:嵌套集合用<collection>做嵌套查询(select属性那种)会触发 N+1 问题——查 10 个订单,每个订单再查一次明细,一共 11 次数据库往返。数据量小的时候感觉不出来,数据量一大就是灾难。替代方案是用join一次查完,靠<collection>的嵌套结果映射来组装。代价是 SQL 变复杂、可能出现笛卡尔积导致的数据重复,需要靠<id>标签帮助框架识别主键去重。这个<id>标签很多人不用,结果集合里出现重复元素,找了半天不知道为什么。

4. 动态 SQL、缓存与插件的能力边界

4.1 动态 SQL 标签的组合与表达式陷阱

动态 SQL 是 MyBatis 相对其他持久层框架最有辨识度的能力,它把"根据条件拼 SQL"这件事从 Java 字符串拼接里解放出来了。但它的标签组合有讲究,用错了照样出问题。

最常用的几个标签是<if>、<where>、<set>、<foreach>、<choose>。其中<where>和<set>的价值不在"少写几个字",而在它们会自动处理开头多余的and/or和结尾多余的逗号。很多人不用<where>,自己手写where 1=1,能跑,但遇到索引优化的时候,某些数据库对1=1这种恒真条件会有额外的优化负担,不是好习惯。

<foreach>的参数名有几个是固定的,collection、item、index、open、separator、close,这几个单词不能随便改。collection的值取决于入参形式:传 List 写list,传数组写array,加了@Param就写注解里的名字。这三个规则记不住就会遇到那句经典报错——“Parameter 'xxx' not found. Available parameters are [arg0, collection, list]”。

<if>里判断字符串非空,很多人写test="name != null and name != ''"。这个写法在字符串场景下是对的,但如果name是单个字符类型或者数字类型,name != ''在某些版本里会触发类型转换异常。更稳的写法是用<if test="name != null and name.trim() != ''">,或者干脆在 Java 侧保证入参干净。这种细节文档里不显眼,但线上真会出问题。

还有个批量插入的经典写法值得记一下:

<insert id="batchInsert"> insert into t_user (name, age) values <foreach collection="list" item="u" separator=","> (#{u.name}, #{u.age}) </foreach> </insert>

这种写法一次往返插入 N 条,比循环单条插入快一到两个数量级。但注意 SQL 长度,某些数据库对单条语句的长度有限制,几千条一次性拼进去会报错,实际工程里要分批,比如每 500 条一批。这个阈值跟数据库类型、max_allowed_packet之类的参数有关,得实测。

4.2 一级缓存和二级缓存分别在什么条件下生效

缓存这一块,我建议新手期先关掉二级缓存,把一级缓存的行为搞清楚就行。原因很简单:缓存是"看起来很美、用错很痛"的典型功能,它带来的问题往往是数据不一致这种最难排查的类型。

一级缓存是SqlSession级别的,默认开启,你没法关(只能通过配置把它的作用域改成语句级)。它的生效条件比较严格:同一个SqlSession、同一条语句、同样的参数、中间没有执行过 insert/update/delete、且没有手动clearCache()。这五个条件里任意一个不满足,缓存就不命中。所以在大多数工程里,一级缓存的实际命中率很低,因为SqlSession通常是请求级的,一个请求里同样的查询重复执行的情况不多。

真正需要注意的是它带来的"脏读":同一个SqlSession里,你先查了一次,然后通过别的方式(比如另一个SqlSession、或者直接改数据库)修改了数据,再查一次,拿到的还是旧值。这个问题在测试环境特别容易出现——两个线程共用了不该共用的 session,或者手动在数据库里改了数据但代码里没感知到。

二级缓存是namespace级别的,跨SqlSession共享,默认关闭,需要在全局配置和 Mapper 里同时开启。它的生效条件更苛刻:必须显式配置<cache/>、被缓存的实体类必须实现序列化接口(因为可能要写到磁盘或者远程传输)、事务必须提交之后才写入缓存、且只在同一个namespace内有效。多表关联查询时,某个表的数据被另一个namespace的语句改了,这边是感知不到的,这就是最典型的缓存不一致来源。

我的实际建议是:单机、读多写少、数据一致性要求不高的字典类查询,可以开二级缓存;有多个写入入口的表,别开。真想上缓存,不如在服务层用一套独立的缓存方案,可控性强得多,出问题也好排查。MyBatis 的缓存能力有它的位置,但它不该是你在项目里第一个想到的方案。

5. 上手阶段的常见问题与排查实录

5.1 高频报错速查表

下面这张表是我这些年攒下来的,基本覆盖了新手前两个月会遇到的八成交错。看到报错先对号入座,比盲目搜索快得多。

报错关键词大概率原因排查动作
Invalid bound statement (not found)namespace 与接口全限定名不符、XML 未被扫描、方法名与 id 不一致检查三处是否完全一致,确认 XML 在编译输出目录里
Parameter 'xxx' not found多参数未加@Param,或collection属性值写错加注解,核对 list/array/自定义名
There is no getter for property#{}里的属性名写错,或误用了${}拼对象逐个核对属性名与 getter
Invalid column typenull 值缺少 jdbcType,或类型不匹配补jdbcType,检查字段类型
TooManyResultsException返回类型声明成单个对象,实际查出多条改成 List,或补全 where 条件
查得到但字段全是 null列名与属性名不匹配且未开自动映射开下划线转驼峰,或用 resultMap
插入成功但数据库没数据未提交事务显式 commit 或开自动提交
集合结果出现重复元素嵌套结果映射缺少<id>标签补主键标识帮助去重

这张表的价值不在于背下来,而在于建立起"报错 → 定位方向"的映射关系。有经验的人看到错误信息第一反应是有方向的,没经验的人第一反应是复制粘贴去搜索,效率差十倍。

5.2 我的排查顺序和几条踩出来的经验

遇到问题我有一套固定的排查顺序,分享出来供参考。

第一步永远是把最终执行的 SQL 打出来。很多问题看一眼真实 SQL 就明白了——参数没进去、条件被<if>吞了、${}拼出了奇怪的东西。开启日志最省事的方式是在全局配置里指定日志实现,把 Mapper 所在包的日志级别调到 DEBUG,SQL、参数、结果行数就都出来了。这一步不做就去猜,纯属浪费时间。

第二步看参数绑定。#{}拿不到值,八成是名字对不上。注意多参数场景下的可用参数列表,报错信息里通常直接告诉你了,照着改就行。

第三步看映射关系。SQL 查出来的列,和resultType/resultMap声明的属性,能不能对齐。我习惯在写复杂映射之前,先用一个简单的resultType="map"查一次,把真实列名打出来看一眼,再去写映射。这一步能省掉大量来回试错。

几条踩出来的经验,写在这里:

第一条,别在 Mapper 接口里写业务判断。Mapper 是数据访问层,它只负责"把这条 SQL 执行好"。判断该不该查、查到之后怎么处理,属于服务层的活。我见过有人在 Mapper 的默认方法里写了一堆分支逻辑,后来要加缓存、要加日志、要换实现,全卡住了。

第二条,SQL 里的select *少用。除了前面说的传输开销,还有个隐蔽问题:一旦表加了字段,select *的结果集列数变了,某些映射方式可能会错位,而且这种错位在测试环境未必暴露。列名显式写出来,多打几个字,省很多事。

第三条,分页别自己拼limit。手写limit #{offset}, #{size}看起来简单,但count查询、参数校验、边界值处理全得自己来,还容易出注入和越界的问题。用成熟的分页方案,它帮你处理了这些细节,而且换了数据库类型还能自动适配。

第四条,写 XML 的 SQL 一定要格式化。缩进、换行、关键字大写,看着是小事,但当你排查一个嵌套三层的动态 SQL 时,格式乱的 SQL 能让你多花半小时。我现在的习惯是每个<where>块的每个条件单独一行,<if>对齐,嵌套层级用两空格缩进。这个习惯从第一天就养成,后面看别人的代码也能快速读懂。

最后分享一个小技巧,专门治"改了 XML 没生效"这个毛病。开发阶段把全局配置里的 Mapper 重新加载打开,或者干脆用热部署工具,改完不用重启。但这个功能在排查"为什么线上和本地行为不一致"的时候要关掉,因为它会掩盖资源加载顺序的问题——而加载顺序恰好是 Mapper 覆盖这类问题的重要线索。工具是工具,得知道它什么时候在帮你、什么时候在骗你。

我个人的体会是,MyBatis 的学习曲线前期陡、后期平。前期陡是因为它把很多决定权交给你了,SQL 怎么写、映射怎么配、事务边界在哪,全要自己判断。但正因为每一个环节都看得见,等你把前面这几块——生命周期、参数绑定、结果映射、动态 SQL、缓存边界——都摸清楚之后,剩下的就是熟练度问题,不会再有"黑盒行为"来突袭你。这也解释了一个现象:用惯了半自动框架的人,转去用全自动框架往往不适应,因为太多东西被藏起来了;反过来倒是顺畅得多。先把这条窄带里的机制搞懂,后面无论换哪个框架,判断力都在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询