资讯详情

资讯详情

MyBatis综合实战:从零搭建员工管理数据访问层

很多朋友最初接触 MyBatis 的时候都是写一堆零散的增删改查真正要在一个 JavaWeb 项目里把 MyBatis 完整用起来反而容易卡壳。这篇综合练习就是把你从“会写 SQL 映射”推到“能搭起一个完整的数据访问层”的高度。我会用一个微型的员工管理模块作为模拟项目 X从工程结构、核心配置、结果映射、动态 SQL 到常见异常排查把整个链路顺一遍全程都是实际开发里每天都会遇到的场景。无论你是跟着课程走到这里的在校学生还是刚转行想补齐基本功的开发者这篇内容都适合你静下心过一遍。1. 综合练习要解决的真实场景1.1 为什么单独把 MyBatis 拎出来做一期练习很多朋友在学习 JavaWeb 的时候数据库操作都是直接 JDBC写一段代码就要处理 Connection、PreparedStatement、ResultSet大量样板代码稍微改个字段就得动 Java 代码。而 MyBatis 的核心价值是把 SQL 从 Java 逻辑里剥离开映射到 XML 或注解中让 Java 代码只关心业务和参数SQL 的优化、调整都不需要重新编译源码。这次的“JavaWEB06”综合练习模拟的是这样一个需求某公司内部要做一个员工管理模块包含员工信息的查询、新增、修改、删除还要支持按部门筛选、按关键字模糊搜索以及批量操作。需要在 MySQL 里建两张表一张是部门表一张是员工表员工表通过外键关联部门表这个设计非常典型足够覆盖 MyBatis 的增删改查、关联查询和动态 SQL 大部分核心特性。这类综合练习的价值在于它不是单个知识点的验证而是把配置文件、实体类、Mapper 接口、XML 映射文件、Service 层组合在一起模拟真实项目的开发节奏。你做完了这个练习再去接手公司里的老项目或者新项目看到那套 Mapper 加 XML 的结构就不会发怵。1.2 技术选型与开发环境准备做这个综合练习我的建议是不要引入太多花活就用最主流、最稳定的一套组合。JDK 8 或 11 都行Maven 做依赖管理数据库使用 MySQL 5.7 或 8.0MyBatis 版本选择 3.5.x这个版本稳资料多。IDE 用 IDEs 全家桶的 Ultimate 版即可社区版配合 Maven 插件也能完成大部分工作不过 Ultimate 对 XML 映射文件有很实用的智能提示做练习效率会高很多。数据库初始化脚本建议自己手写一遍这是基本功不要直接复制别人的。我建的模拟项目 X 中dept 表有 id、name 字段employee 表有 id、name、gender、salary、dept_id 字段。注意 employee 表的 dept_id 设置外键约束关联 dept 表的 id这样在 MyBatis 里做关联查询时才会有真实的数据关系。插入几条测试数据确保后续查询能够看到结果。准备阶段有一个小地方容易踩坑MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.DriverMySQL 5.7 则是com.mysql.jdbc.Driver。另外连接 URL 最好加上useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8不然控制台经常蹦出时区或者 SSL 的警告虽然不影响执行但看着很闹心。2. 工程结构与核心配置搭建2.1 Maven 依赖与工程目录划分新建一个 Maven 项目packaging 选 jar 就可以。如果你是非 Web 的综合练习不需要打成 war 包后面测试可以直接写 JUnit 单元测试来跑 Mapper。依赖方面需要引入 mybatis、mysql-connector-java、junit 这三个如果你后面想加日志再引入 log4j 或 logback。工程结构我强烈建议你从第一行代码开始就保持规范src/main/java ├─ com.example.entity // 实体类 ├─ com.example.mapper // Mapper 接口 ├─ com.example.service // Service 层 └─ com.example.test // 测试类 src/main/resources ├─ mybatis-config.xml // 核心配置 └─ mapper/ // Mapper XML 映射文件不要偷懒把 XML 和接口放在不同目录但同名很多时候接口和 XML 的包路径不一致会导致绑定失败。当然如果在 Spring 环境里你可以用 mapper-locations 去指定任意路径但在纯 MyBatis 练习里最简单的方式是让 XML 放在 resources 下的 mapper 目录并把 namespace 指向接口全限定名。2.2 mybatis-config.xml 各项配置逐个说清楚核心配置文件是整个 MyBatis 的入口我在练习里用的是下面这个配置每一段都有讲究?xml version1.0 encodingUTF-8 ? !DOCTYPE configuration PUBLIC -//mybatis.org//DTD Config 3.0//EN https://mybatis.org/dtd/mybatis-3-config.dtd configuration properties resourcedb.properties/ settings setting namelogImpl valueSTDOUT_LOGGING/ setting namemapUnderscoreToCamelCase valuetrue/ /settings typeAliases package namecom.example.entity/ /typeAliases environments defaultdevelopment environment iddevelopment transactionManager typeJDBC/ dataSource typePOOLED property namedriver value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /dataSource /environment /environments mappers package namecom.example.mapper/ /mappers /configurationproperties把数据库连接信息放到独立的 db.properties 文件里以后换库只改一个文件。settings里的logImpl设为STDOUT_LOGGING可以在控制台直接看到 MyBatis 执行的 SQL 和参数这对排查问题极其重要很多初学者说 SQL 写错了又找不到原因就因为没有打开日志。mapUnderscoreToCamelCase开启后数据库的dept_id可以自动映射到实体的deptId少写很多 resultMap。typeAliases直接把实体包整体注册XML 里写返回值类型时不用写全类名写Employee就行。mappers直接扫描接口包但前提是接口与 XML 文件同名且在同一级 classpath 目录下否则这里要用mapper resourcemapper/EmployeeMapper.xml/一个一个注册效率低且容易漏。事务管理器在这里选 JDBC表示事务由 MyBatis 自己管理提交回滚都手动控制。以后整合 Spring 或 Spring Boot 时这一步会被框架接管但你现在必须理解它。2.3 日志、别名与下划线映射的细节为什么我特别强调日志和别名因为在你学习阶段它们直接决定你的排查效率。开启STDOUT_LOGGING之后控制台会输出类似这样的信息 Preparing: SELECT * FROM employee WHERE dept_id ? Parameters: 1(Integer) Columns: id, name, gender, salary, dept_id Row: 1, 张三, 男, 8000, 1 Total: 1你一眼就能看出 MyBatis 到底往数据库发了什么 SQL参数有没有正确传递。如果 SQL 和参数都没问题但结果是空再去查数据如果 SQL 本身和预想不一致就查 XML 中的标签拼接逻辑。这个习惯必须养成直接看日志定位不要靠猜。别名的细节在于如果不配置typeAliasesresultType 必须写com.example.entity.Employee配置了包扫描后只需要写Employee。不过要注意别名不区分大小写实体类如果重名会有冲突所以一个模块里的实体类命名要规范别出现同名类在不同包里。mapUnderscoreToCamelCase解决的是“驼峰属性对下划线字段”的问题前提是实体类属性名必须符合驼峰命名规范比如类的属性是deptId而不是deptid。3. 实体类与映射文件的对应关系3.1 实体类设计中的常见坑实体类是对数据库表的“内存镜像”字段名、类型要尽量和表对齐但很多初学者的通病是数据库字段叫gender实体属性叫sex数据库用TINYINT表示性别实体里用String。这种不一致虽然在 MyBatis 里可以通过 resultMap 硬映射但完全没有必要给自己挖坑设计实体类的时候直接对着表字段来宁可属性多一两个不影响也不要减少核心字段。员工实体我建议这样写public class Employee { private Integer id; private String name; private String gender; private Double salary; private Integer deptId; // 关联对象存一个部门信息而不是存某个字符串 private Dept dept; // getter/setter/toString }很多人会把“员工属于哪个部门”设计成deptName字符串这是不合理的。因为你始终要知道一个员工对应一条部门信息这条部门信息有 id 又有 name还可能有别的字段。更符合面向对象的方式是在 Employee 里放一个Dept对象的引用这就是后面association要处理的内容。这个设计模式你要尽早熟悉否则后期做复杂查询时Java 代码里全是零散字段拼接。3.2 resultType 与 resultMap 的选用逻辑resultType 的原理是MyBatis 根据你指定的类型把查询结果的列名和实体类的属性名做自动映射如果列名和属性名完全一致或者满足下划线转驼峰规则就能直接填充。resultMap 则是你手动声明“哪一列对应哪个属性”更灵活也更繁琐。我给你的建议是单表查询、字段命名规范的前提下优先用resultType 下划线转驼峰一旦遇到多表关联、字段重名、或实体属性与表字段差异较大用resultMap。比如查出一个员工带着他的部门信息如果直接 resultType 返回 Employee那 dept 表的 name 列无法自动填充到employee.dept.name里就必须写 resultMap通过 association 把 dept 表的数据塞到对象里去。3.3 多对一关联查询的 resultMap 配置多对一是“多个员工属于同一个部门”所以我们查询员工的时候要把关联的部门也查出来。SQL 可以这样写select idselectEmployeeWithDept resultMapemployeeWithDeptResultMap SELECT e.id, e.name, e.gender, e.salary, e.dept_id, d.id dept_id, d.name dept_name FROM employee e LEFT JOIN dept d ON e.dept_id d.id WHERE e.id #{id} /select这里的重点在 resultMapresultMap idemployeeWithDeptResultMap typeEmployee id propertyid columnid/ result propertyname columnname/ result propertygender columngender/ result propertysalary columnsalary/ result propertydeptId columndept_id/ association propertydept javaTypeDept id propertyid columndept_id/ result propertyname columndept_name/ /association /resultMap注意我在查询里把 d.id 取别名为 dept_idd.name 取别名为 dept_name避免和 employee 的列名冲突。在 association 内部column 写的是 SQL 查询结果的列名也就是别名后的名字。如果你不加别名两条记录都叫 idMyBatis 映射时可能拿到的是同一个值这就是很多同学“数据看起来对但部门 id 总是变成员工 id”的原因。一对多方向比如查一个部门连同下面所有员工就要用collection propertyemployeeList ofTypeEmployee。我认为初学者应该先把多对一熟练掌握一对多会用到嵌套结果映射本质还是同一套只是 resultMap 的 collection 节点里再放一层 result 映射。4. 动态 SQL 的实战运用4.1 if where 解决多条件组合查询真实项目里最常遇到的需求就是“筛选条件不固定”。比如员工列表页面用户可能选择只看某个部门的员工可能按姓名模糊搜索也可能两个条件都不选直接全查。最笨的写法是拼三个 SQL查全部、按部门查、按姓名查。更笨的写法是在 Java 代码里用 if 拼 SQL 字符串这是 JDBC 时代各大老项目的痛点。MyBatis 动态 SQL 里最常用的就是 if 和 where 搭配select idsearchEmployees resultTypeEmployee SELECT * FROM employee where if testdeptId ! null AND dept_id #{deptId} /if if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if /where /selectwhere 标签干的活是如果里面任一条件成立它会自动加一个 WHERE 关键字如果第一个条件前面有 AND 或 OR它会自动帮你去掉。这样一来你永远不需要担心“查询条件前面多一个 AND”这种低级错误。对比一下如果不使用 where 标签你的 SQL 大概率会写成WHERE 11再拼接条件这种写法能跑不等于规范等于把所有脏活都交给了数据库去扫描。LIKE 模糊搜索我用了CONCAT(%, #{name}, %)而不是直接LIKE %#{name}%因为 #{} 在 MyBatis 里会生成占位符?字符串拼接操作不能在占位符里做写错了会直接报 SQL 语法错误这是新手高频报错之一。4.2 set 标签做动态字段更新更新员工信息时如果页面提交的是完整表单直接UPDATE employee SET name?, gender?, salary?, dept_id? WHERE id?毫无问题。但真实项目里经常有“只修改某个字段”的需求比如管理员只改一个工资其他字段不传。如果用全量更新 SQL没有传的字段全被覆盖成 NULL这就出事故了。动态 set 标签可以解决这个问题update idupdateEmployee UPDATE employee set if testname ! null and name ! name #{name}, /if if testsalary ! null salary #{salary}, /if /set WHERE id #{id} /updateset 标签会自动去除最后一个多余的逗号这个特性很关键。如果你自己拼 SQL经常会因为逗号位置不对而报 SQL 语句错误set 标签帮你去掉末尾逗号只要保证每个 if 里最后一个字符是逗号就行它处理后会自动修正。这里有个实战心得动态 update 适合“部分字段更新”但前提是你能保证传入的对象里没值就代表不需要更新。如果业务上可能要把某个字段更新成 NULL就不能依赖这种方式得单独写一个明确置 NULL 的 SQL这个取舍要在设计接口前想清楚。4.3 foreach 实现批量插入与批量删除批量操作在 MyBatis 里几乎都靠 foreach 标签实现。拿批量插入员工来说最原始的循环单条 insert 是灾难级的慢一条一个事务网络来回消耗巨大。用 foreach 一条语句插入多条性能好一个数量级insert idbatchInsertEmployees INSERT INTO employee(name, gender, salary, dept_id) VALUES foreach collectionemployees itememp separator, (#{emp.name}, #{emp.gender}, #{emp.salary}, #{emp.deptId}) /foreach /insertcollection 的值employees是 Mapper 接口方法的参数名。如果接口方法没有用Param注解指定参数名可以传入list或collectionMyBatis 内部会有默认规则加了Param(employees)则必须写employees这个规则不要搞混。批量删除也用 foreach通常配合IN语句delete iddeleteByIds DELETE FROM employee WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /deleteopen、separator、close 三个属性合起来把列表包成(1,2,3)的形式。需要注意批量删除一次删太多数据可能触发数据库的 SQL 长度限制所以在接口设计上可以限制一次最多删除 500 条左右这是实际项目经常做的一个约束。4.4 choose 与 otherwise 实现多选一逻辑有时候业务是这样的传了部门就按部门查没传部门但传了姓名就按姓名查两个都没传就查全部。这种“多选一”的分支逻辑MyBatis 里用 choose when otherwise 实现效果类似于 Java 里的 switchselect idselectByCondition resultTypeEmployee SELECT * FROM employee where choose when testdeptId ! null dept_id #{deptId} /when when testname ! null and name ! name LIKE CONCAT(%, #{name}, %) /when otherwise 1 1 /otherwise /choose /where /selectchoose 从上往下匹配一旦某个 when 条件成立后面的 when 不会再判断。otherwise 是所有条件都不满足时的兜底方案。这种写法比颈椎病式的一长串 if 更优雅语义也清楚。实际开发中业务上经常是“某条件优先其他条件无效”这种多选一逻辑用 if 也能实现但可读性远不如 choose。5. 数据访问层完整实现链路5.1 Mapper 接口与 XML 命名空间的强制绑定综合练习到这一步就要验证你对“接口和 XML 是怎么连起来”的理解。MyBatis 之所以允许只写接口不写实现类是因为它在运行时通过 JDK 动态代理为每个接口方法生成实现。前提是XML 的 namespace 必须等于接口的全限定名接口方法名必须等于 XML 中某个 statement 的 id接口方法的返回类型和参数类型要和 XML 中 resultType、parameterType 匹配。比如接口是com.example.mapper.EmployeeMapperXML 的 namespace 就要写mapper namespacecom.example.mapper.EmployeeMapper接口方法Employee selectEmployeeById(Integer id);XML 中select idselectEmployeeById resultTypeEmployee这样调用employeeMapper.selectEmployeeById(1)时MyBatis 就会去执行 id 为selectEmployeeById的 SQL。如果你的接口方法名和 XML 的 id 不一致运行到调用该方法时就会抛出BindingException这个错误在第 6 部分我会展开说。很多初学者把 namespace 写错、或者方法名大小写差一个字母就能卡半小时这类问题要学会看异常栈的第一行信息。5.2 新增数据时主键回填新增员工时数据库自增 id 是在执行 insert 后由数据库生成的。如果业务上需要在 Java 代码里立刻拿到这个新生成的 id比如后面要维护员工和角色的关联关系就需要开启主键回填insert idinsertEmployee useGeneratedKeystrue keyPropertyid INSERT INTO employee(name, gender, salary, dept_id) VALUES (#{name}, #{gender}, #{salary}, #{deptId}) /insertuseGeneratedKeys 告诉 MyBatis 要使用 JDBC 的getGeneratedKeys()方法把自增主键取回来keyProperty 指定取回来的值塞到传入参数的哪个属性里。执行完 insertEmp 方法后你传入的 employee 对象的 id 属性就会被填上真实的自增 id。这个功能在你做“先插入主表再插入从表”的业务时至关重要。有一点容易忽略keyProperty 必须对应实体类的属性名而不是数据库的列名。如果你的实体属性叫idkeyProperty 就写id不是ID也不是emp_id。5.3 参数传递的三种方式与注意事项Mapper 接口方法传参我总结了三种场景。第一种是单个参数比如根据 id 查员工直接在 SQL 里用#{id}就可以名字可以任意但最好和参数名一致方便阅读。第二种是多个参数必须使用Param注解否则 MyBatis 会按param1、param2这种默认名字来取值代码可读性极差ListEmployee selectByDeptAndGender(Param(deptId) Integer deptId, Param(gender) String gender);XML 里直接使用#{deptId}、#{gender}。第三种是一个对象参数比如新增、更新操作直接传入 Employee 对象SQL 里#{name}就表示取对象里的 name 属性。还有一类特殊情况是多参数加对象比如既有范围参数又有查询条件对象这时可以用Param(emp) Employee empXML 里通过#{emp.name}访问。关于参数名还有一个新版的小知识从 MyBatis 3.5.1 开始如果编译时加了-parameters参数单参数场景下可以不写Param也能直接用参数名但如果依赖这个特性你的开发环境和部署环境必须保证编译参数一致。为了保险我还是建议多参数场景显式加Param这是任何环境下都能跑的习惯。5.4 Service 层与事务的简单封装综合练习如果只停留在 Mapper 层那还不算完整。我建议你加一个 Service 层把一次业务操作涉及到的多个 Mapper 方法组合起来。Service 的核心价值在于事务边界比如“新增员工时同时往日志表插入一条操作记录”如果第二个操作失败第一个操作必须回滚不然数据就乱了。纯 MyBatis 下的事务控制逻辑长这样SqlSession session sqlSessionFactory.openSession(false); try { EmployeeMapper mapper session.getMapper(EmployeeMapper.class); mapper.insertEmployee(emp); logMapper.insertLog(新增员工 id emp.getId()); session.commit(); } catch (Exception e) { session.rollback(); throw e; } finally { session.close(); }openSession 的参数传 false表示关闭自动提交开启事务commit 和 rollback 要写在同一个 try-catch 逻辑里。这个模式你以后看 Spring 事务源码时会发现底层原理完全一致只是 Spring 帮你省掉了这些样板代码把事务边界交给你用注解声明。练习阶段手写一遍事务能建立非常扎实的概念认知。6. 常见问题与排查技巧实录6.1 BindingException接口与 XML 绑定失败这类异常属于“练习阶段出现频率之王”教我见过的场景至少一半的新手都会撞上。错误信息一般长这样org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.mapper.EmployeeMapper.selectEmployeeById这句话的中文含义是MyBatis 找到了你的 Mapper 接口但找不到对应 id 的 SQL 语句。产生的原因按概率排序第一XML 文件的 namespace 写错没有指向接口全限定名第二XML 文件没有被扫描到也就是在 mybatis-config.xml 里没有正确配置 mapper 加载路径第三接口方法名和 XML 中 select/insert 的 id 不一致第四XML 文件放的位置不对比如放进了 resources 之外的目录编译后没有出现在 classpath 里。排查顺序我推荐这样先看配置文件里mappers用的是包扫描还是 resource 注册包扫描要求接口与 XML 同名而且在同一个 classpath 包路径下再看 namespace必须和接口全限定名一模一样最后看方法名和 statement id注意大小写。这三点检查完了基本都能解决。6.2 数据库列与实体属性映射不上的原因另一个高频问题是SQL 查询结果返回了但实体类的某些属性是 null。最典型的场景是列名为dept_id属性名为deptId而你忘了开启mapUnderscoreToCamelCase。开了之后MyBatis 会自动去掉下划线并把后一个字母大写映射到对应属性。第二种情况是关联查询时多表字段重名。比如 dept 表有 nameemployee 表也有 name现实中可能不叫 name但很多表就是会有相同列名查询结果集里两列都叫 nameMyBatis 默认会把后查到的重复列覆盖前面的值。解决办法就是我在前面的 resultMap 里演示的给查询列起别名并且在 resultMap 里用别名做映射。第三种比较隐蔽数据库类型和 Java 类型不匹配导致值为 null。比如数据库数是TINYINT实体属性是Integer一般没问题但如果你用了BIGINT配Long字段就应该是 Long如果用 Integer 去接 Long 的数据虽然大部分情况不会报错但遇到超长数字时可能溢出。实体类设计时就要用数据库范围的偏大类型这是经验之谈。6.3 SQL 语法错误集中在哪些环节我观察到的 SQL 语法错误集中在三块。第一块是动态 SQL 拼出来的语句不对比如 where 标签没用导致WHERE ANDset 标签没用导致结尾多了逗号foreach 的 separator 写错了位置。这些干了两次就记住了最好的方法是在日志里看 MyBatis 最终生成的 SQL不要只盯着 XML 原文看生成后的效果才是重点。第二块是#{}和${}的使用问题。${}是字符串拼接直接把内容拼进 SQL不经过预编译存在 SQL 注入风险#{}是预编译占位。在 LIKE 查询中直接写%#{name}%是错的SQL 执行时占位符不能出现在字符串里必须用CONCAT(%, #{name}, %)。如果你确实需要动态拼接表名、列名这类内容那才用${}但一定不能来自外部用户的直接输入必须是代码内部白名单映射后的值。第三块是日期类型。Java 的LocalDate、LocalDateTime在 MyBatis 里通常能被默认处理但如果你的数据库驱动版本太老或者没有设置serverTimezone会报时间转换错误或时区偏移。6.4 连接与资源关闭的隐藏雷区代码写得再对连接池配置得不好程序跑起来同样会抽风。纯 MyBatis 的SqlSession必须主动关闭因为底层占用了数据库连接不关闭会导致连接耗尽。很多人写测试代码一个循环里 openSession 后不 close跑两次就报Too many connections这是数据库连接池被榨干的典型症状。还有事务问题如果 openSession 不传参默认是开启自动提交的每次执行完 SQL 就直接提交。但在“先插员工再插日志”这种组合操作里你可能希望在两条都成功后再一起提交。这个前面说过用openSession(false)手动控制。另外如果你准备把项目升级到 Spring 环境MyBatis 的事务控制和资源关闭就不再由你代码里手动管理而是交给了框架的数据源和事务管理器。所以你现在手写的“开-提交-回滚-关”这套流程本质上是在理解框架底层的行为。理解了它以后用 Spring 就特别顺。6.5 一句代码解决测试环境 SQL 日志最后分享一个让排查效率翻倍的习惯在本地练习的测试代码里加入 MyBatis 的日志实现。配置里logImplSTDOUT_LOGGING是最省事的它直接把日志打到控制台。如果你的项目里已经有 log4j就配置成logImplLOG4J。我看到很多同事带着一个排错效率低的问题SQL 报错了控制台不输出任何 SQL只抛一个底层 Exception于是只能靠猜。其实很多时候就是少了这一行日志配置。我也想提醒一下如果你的 SQL 是动态拼接的日志里打印的是 MyBatis 最终执行的那条完整 SQL这跟你 XML 文件里写的可能有差别。看到打印出来的 SQL把它复制到数据库客户端工具里执行一遍立刻能定位是语法问题还是业务数据问题。这一步基本功能帮你省下大量和同事来回沟通的时间。7. 我对这个练习的整体感受把 MyBatis 综合练习完整过一遍最大的收获不是记住了几个标签而是建立起了“配置文件 — 接口 — XML — 结果映射”这一整套思维模型。我见过太多人在 Spring Boot 里用 MyBatis 写代码出了问题连 namespace 都不知道去哪看就是因为前期纯 MyBatis 的底子没打好。综合练习的力量就在于此它逼着你把每个环节都亲手摸一遍而不是被框架的自动配置掩盖掉。另外我特别想说代码这个东西光看真的学不会。你可以把这篇练习里的结构抄到自己的项目里去改把用户名换成你自己定义的员工表把部门表换成角色表多改两遍那些曾经觉得拗口的配置就会变成肌肉记忆。等你回过头来看下一篇更复杂的框架整合时会感谢今天认认真真做完的这次练习。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →