首页 / 资讯中心 / 文章详情

JDBC元数据驱动MyBatis Mapper XML自动生成与避坑

JDBC元数据驱动MyBatis Mapper XML自动生成与避坑 ★ FEATURED ARTICLE
简介XML文件自动生成工具面向需要从数据库快速导出结构化XML的Java开发者及数据交换、配置生成场景核心是利用JDBC连接数据源并执行SQL将结果集转换为带层级关系的XML元素降低手写标签、处理转义与语法校验的成本。压缩包为7z格式共4个文件、约144KB内含可执行程序、初始化配置、JDBC连接属性以及XML数据源示例修改数据库参数后即可用于生成输出。已有2354人学习关注在配置化生成XML与JDBC数据转换方面具有一定参考价值。使用者可了解字段到元素映射、一对多等关系处理、根元素与命名空间、编码设置以及按XSD验证XML的思路对需要批量导出或反复生成XML的项目它能减少重复编写与人工检查适合作为轻量级效率工具和数据库转XML的实现样例。1. 手写第 37 个 Mapper XML 时我决定让 JDBC 元数据替我干活上个月接手一个后台系统的维护资源里躺着一份 xml 文件自动生成工具打开一看思路很朴素拿 jdbc 连上库把 datasource 指向的那套表结构读出来直接吐成 MyBatis 的 Mapper XML 和配套配置。当时我的第一反应是这玩意儿靠谱吗因为手上正卡在第 37 个手写的 Mapper 上——每张表一个 resultMap、五条基础 SQL字段名少写一个下划线启动就报Invalid bound statement。这个工具解决的正是这类重复劳动库表结构是唯一事实来源XML 是它的投影投影就该由机器来做。适合的人群也很明确——手里有几十张以上表、表结构还在持续变动、团队里没人愿意承认自己手写 resultMap 从来不出错的后端开发者。不适合的是只有三张表的小项目手写比配工具快。下面按我实际拆包、跑通、踩坑的顺序讲。2. 连库与取数从 DataSource 到一张表结构快照的三步2.1 三种拿连接的方式生成器该选哪种工具包里给了三条接库路径我挨个试了一遍结论是各有各的场合别混着用。第一条是裸DriverManager给一段 URL 直接连。好处是零依赖坏处是凭据要写在命令行或环境变量里适合临时跑一次。// 方式一一次性生成用完即走。账号必须是只读账号 String url jdbc:mysql://127.0.0.1:3306/demo ?useUnicodetruecharacterEncodingutf8 useInformationSchematrue; try (Connection conn DriverManager.getConnection(url, meta_reader, meta_reader_pwd)) { // 只做元数据读取不跑任何业务 SQL generate(conn, demo); }第二条是复用项目里现成的DataSource。这条我最推荐因为它意味着生成器和业务系统连的是同一个库、同一套字符集、同一份时区设置生成出来的 XML 不会出现工具里正常、上线后乱码的分裂。// 方式二从容器或配置里拿 DataSource连接参数与业务侧完全一致 DataSource ds dataSourceFactory.fromYaml(src/main/resources/application.yml); try (Connection conn ds.getConnection()) { generate(conn, conn.getCatalog()); // catalog 就是当前库名 }第三条是读.properties里的一组 datasource 配置支持多套库切换。这个在跨环境生成时有用——开发库和测试库表结构有差异同一份 XML 不能通用必须按环境各生成一份。提示生成器用的账号给SELECT权限就够别图省事用业务主账号。元数据读取会扫information_schema权限过大反而容易在审计里留下痕迹。2.2 getColumns 返回的那张表真正有用的只有九列DatabaseMetaData.getColumns()返回的 ResultSet 有二十多列绝大多数用不上。我把实际参与生成逻辑的列列出来剩下的直接忽略代码会干净很多。结果集列名含义生成时怎么用COLUMN_NAME原始列名写进 XML 的column属性一个字都不能改TYPE_NAME数据库侧类型名映射成 MyBatis 的jdbcType注意大小写DATA_TYPEjava.sql.Types整型常量映射成 Java 属性类型比字符串比较可靠COLUMN_SIZE长度/精度生成校验注解或 DDL 注释时用DECIMAL_DIGITS小数位DECIMAL/NUMERIC才有效其余返回 nullNULLABLE是否可空决定是否生成if testxx ! null分支ORDINAL_POSITION列序保证生成顺序与建表顺序一致diff 时才不会满屏红REMARKS列注释生成 XML 注释团队协作时价值很高IS_AUTOINCREMENT是否自增决定 insert 语句是否排除该列主键不在getColumns里得单独调getPrimaryKeys()。这里有个细节联合主键返回多行每行带KEY_SEQ必须按它排序否则生成的where条件顺序漂移虽然 SQL 语义不变但每次重新生成都会产生无意义的 diff。IS_AUTOINCREMENT这一列是个坑。JDBC 4.1 之后标准里有了但不同驱动实现质量参差有的直接返回空字符串。我一般做双路探测先读元数据读不到再用ResultSetMetaData.isAutoIncrement()兜底。// 兜底探测自增列执行一条不返回数据的查询从结果集元数据里问 try (Statement st conn.createStatement(); ResultSet rs st.executeQuery(SELECT * FROM tableName WHERE 10)) { ResultSetMetaData rsmd rs.getMetaData(); for (int i 1; i rsmd.getColumnCount(); i) { if (rsmd.isAutoIncrement(i)) { tableMeta.markAutoIncrement(rsmd.getColumnName(i)); } } }WHERE 10的作用是让数据库只做语法解析和计划生成不实际扫描数据。对百万行的大表这条语句的耗时基本在毫秒级比LIMIT 0更通用——不是所有库都认LIMIT。2.3 把元数据落成内存模型别边读边拼字符串这是我看这个工具源码时最认同的一处设计读元数据和生成 XML 完全解耦中间隔一层TableMeta/ColumnMeta。好处是生成逻辑可以单独测改模板不用碰 JDBC 代码坏处是多写两个类。值得。// 列模型只保留生成 XML 真正需要的字段 public class ColumnMeta { private String columnName; // 原始列名如 user_name private String propertyName; // 转换后属性名如 userName private String jdbcType; // 大写后的类型名如 VARCHAR private int sqlType; // java.sql.Types 常量 private int size; private int scale; private boolean nullable; private boolean autoIncrement; private boolean primaryKey; private int keySeq; // 联合主键时的顺序 private String remarks; // getter / setter 省略 }读取顺序也有讲究先读表清单再逐表读主键最后读列。因为主键信息要回填到列对象上反过来做就得读两遍列。// 逐表采集主键先行列信息回填 public TableMeta readTable(Connection conn, String catalog, String schema, String table) throws SQLException { TableMeta meta new TableMeta(table); DatabaseMetaData md conn.getMetaData(); // 第一步主键。KEY_SEQ 决定联合主键的字段顺序 try (ResultSet pk md.getPrimaryKeys(catalog, schema, table)) { while (pk.next()) { meta.addPrimaryKey(pk.getString(COLUMN_NAME), pk.getShort(KEY_SEQ)); } } // 第二步列。ORDINAL_POSITION 保证输出顺序稳定 try (ResultSet rs md.getColumns(catalog, schema, table, %)) { while (rs.next()) { ColumnMeta c new ColumnMeta(); c.setColumnName(rs.getString(COLUMN_NAME)); c.setJdbcType(rs.getString(TYPE_NAME).toUpperCase()); c.setSqlType(rs.getInt(DATA_TYPE)); c.setSize(rs.getInt(COLUMN_SIZE)); c.setScale(rs.getInt(DECIMAL_DIGITS)); c.setNullable(rs.getInt(NULLABLE) DatabaseMetaData.columnNullable); c.setRemarks(rs.getString(REMARKS)); meta.attachColumn(c); // 内部按列名把主键标记回填到 c } } return meta; }getColumns的第四个参数是列名通配符传%表示全部。第二个、第三个参数是catalog和schema这两个值填错是最常见的读不到表原因我放到第 4 章单独说。3. 模板落笔把列信息拼成 resultMap 与五条 CRUD 节点3.1 列名转属性名自动转换只能解决八成下划线转驼峰是标配但真跑起来会发现例外比规则多。工具里那段转换逻辑很短我抄出来贴在这// 下划线转小驼峰user_name - userNameURL - url static String toCamel(String col) { StringBuilder sb new StringBuilder(); boolean upperNext false; for (char ch : col.toLowerCase().toCharArray()) { if (ch _) { upperNext true; continue; } sb.append(upperNext ? Character.toUpperCase(ch) : ch); upperNext false; } return sb.toString(); }先说这个实现的问题toLowerCase()在前意味着ORDER_ID和order_id转出来一样这在大多数库上是对的但遇到区分大小写的列名就会撞车。另外像URL、ID_CARD、IP_ADDR这类全大写缩写转成url、idCard、ipAddr之后如果代码里硬编码的是URL运行时就是There is no getter for property named url。我的做法是留一张覆盖表跟着配置走# 生成器配置里的属性名覆盖优先级高于自动转换 columnOverrides: - table: sys_user column: URL property: homeUrl - table: sys_user column: ID_CARD property: idCardNotable留空表示全局生效。这张表平时是空的一旦某个字段在业务代码里已经被引用就加一条锁住避免哪天有人改了转换规则导致全项目编译不过。3.2 resultMap 生成主键用id普通列用resultresultMap 是整份 XML 里最容易写错的部分因为它是列名到属性名的显式声明错一个字段就是启动失败或者查出来全是 null。生成逻辑不难关键是判断分支要全。resultMap idBaseResultMap type${table.className} #list table.columns as c #if c.primaryKey id column${c.columnName} property${c.propertyName} jdbcType${c.jdbcType}/ #else result column${c.columnName} property${c.propertyName} jdbcType${c.jdbcType}/ /#if /#list /resultMap上面是 FreeMarker 模板片段${}里是模型字段。用模板引擎而不是 Java 字符串拼接好处是缩进和换行天然正确——手拼的 XML 缩进永远歪review 时特别难受。jdbcType这个属性不能省。省了之后当某个字段值为null时MyBatis 会给PreparedStatement.setNull()传一个默认类型部分驱动尤其是 PostgreSQL会直接抛column xxx is of type timestamp but expression is of type bytea。写上jdbcType就没这问题。jdbcType的取值必须匹配 MyBatis 的枚举名全大写。但数据库返回的TYPE_NAME五花八门MySQL 返回VARCHARPostgreSQL 返回varcharSQL Server 返回varchar某些商业库返回INT4、DATETIME2。所以中间必须有一张映射表。JDBCTYPE_NAME常见值MyBatisjdbcTypeJava 类型VARCHAR/VARCHAR2/NVARCHARVARCHARStringCHAR/NCHARCHARStringTEXT/LONGTEXT/CLOBLONGVARCHARStringINT/INTEGER/INT4INTEGERIntegerBIGINT/INT8BIGINTLongSMALLINT/INT2SMALLINTShortDECIMAL/NUMERICDECIMALBigDecimalFLOAT/REALREALFloatDOUBLE/FLOAT8DOUBLEDoubleDATEDATELocalDateTIMETIMELocalTimeTIMESTAMP/DATETIMETIMESTAMPLocalDateTimeBIT/BOOL/BOOLEANBOOLEANBooleanBLOB/BYTEABLOBbyte[]映射表要按DATA_TYPE整型常量做一级判断TYPE_NAME只做二次校验。原因很实在TYPE_NAME是字符串各库各版本都在变java.sql.Types是 JDK 常量稳定得多。我在映射表里给Types.OTHER留了一条兜底规则——遇到不认识的类型先按VARCHAR处理同时在日志里打一行警告让人知道这里需要人工补规则而不是悄悄生成一份跑不起来的 XML。3.3 五条 CRUD 的分支差异全在主键上selectByPrimaryKey、insert、insertSelective、updateByPrimaryKey、deleteByPrimaryKey这五条是模板的固定套路但每条都有依赖主键形态的分支这里翻车最多。单自增主键最简单insert 里要把自增列排除掉并加上useGeneratedKeysinsert idinsertSelective parameterTypecom.example.entity.SysUser useGeneratedKeystrue keyPropertyid INSERT INTO sys_user trim prefix( suffix) suffixOverrides, if testuserName ! nulluser_name,/if if teststatus ! nullstatus,/if /trim trim prefixvalues ( suffix) suffixOverrides, if testuserName ! null#{userName,jdbcTypeVARCHAR},/if if teststatus ! null#{status,jdbcTypeTINYINT},/if /trim /insert模板里判断自增用的是column.autoIncrement一旦这个标记因为驱动问题没读出来id就会被写进 insert 列表插入时传nullMySQL 严格模式下报Column id cannot be null非严格模式下更阴——插进去一行 id 为 0 的记录第二次插就撞主键。这个坑我后面第 5 章细说。联合主键的差异在delete和select的where段多个条件用AND连接且顺序必须按KEY_SEQ稳定输出否则每次重新生成都是一次全文件 diff。无主键表是最需要小心的一类。表结构里没有主键getPrimaryKeys返回空如果模板不加判断照样生成update和delete那生成的语句就是不带where的全表更新。我在生成器里加了一条硬规则// 无主键表只允许生成查询语句写操作一律跳过并记录 if (meta.getPrimaryKeys().isEmpty()) { log.warn(表 {} 无主键跳过 insert/update/delete 生成, meta.getTableName()); meta.setReadOnly(true); }日志里出现这行警告说明数据库设计本身有问题该补主键补主键别指望生成器兜底。3.4 生成的 XML 放哪比怎么生成更重要工具默认输出到src/main/resources/mapper/我把它改到了target/generated-sources/mapper/然后在 MyBatis 配置里加一条扫描路径。理由是生成产物不该进版本库。判断标准很简单——如果一份文件可以随时由数据库重新算出来那它就不该被git管理。放进版本库的直接后果是每次有人改表结构提交里就混进几十个 XML 的 diff真正的业务改动淹没在里面。改成输出到target之后每次构建重新生成git status永远是干净的。代价是 IDE 里看不到这些文件跳转会失效。折中方案是生成到src/main/resources/mapper/generated/在.gitignore里排除这个子目录。IDE 能索引版本库不受污染。4. datasource 与方言URL、catalog、schema 三处最容易对不上4.1 从 URL 反推方言做三件差异化的事工具里有个小函数负责从 JDBC URL 前缀判断数据库类型看着简陋但确实是分流的起点// 只认前缀够用了。多数据源场景下每个 URL 单独判断 static DbType detect(String url) { if (url null) return DbType.UNKNOWN; if (url.startsWith(jdbc:mysql:)) return DbType.MYSQL; if (url.startsWith(jdbc:postgresql:)) return DbType.POSTGRESQL; if (url.startsWith(jdbc:sqlserver:)) return DbType.SQLSERVER; if (url.startsWith(jdbc:mariadb:)) return DbType.MYSQL; // 元数据行为基本一致 return DbType.UNKNOWN; }识别出方言之后实际要做的差异化只有三件标识符的引用符反引号 / 双引号 / 方括号、分页语法LIMIT/OFFSET FETCH/ROWNUM、以及类型名映射表。前面两件影响生成的 SQL 文本第三件影响jdbcType。有些团队喜欢在生成器里塞一个万能方言层我试过不划算。生成器不是 ORM它只需要产出正确的文本不需要运行时适配。真要跨库就按库生成不同的 XML 目录mapper/mysql/、mapper/pg/各管各的。4.2 catalog 与 schema填错就是一张表都读不到这是我在拆包过程中耗时最长的一段。getTables(catalog, schemaPattern, tableNamePattern, types)前两个参数在不同数据库里含义完全不同填错了返回空结果集而且不报错就是安安静静地什么都没有。数据库catalog传什么schemaPattern传什么常用typesMySQL库名如demonull{TABLE}PostgreSQL库名或null模式名如public{TABLE}SQL Server库名dbo或null{TABLE}通用稳妥写法conn.getCatalog()conn.getSchema(){TABLE}最稳的写法是让驱动自己回答而不是硬编码// 让连接告诉你当前的 catalog / schema比自己猜可靠 String catalog conn.getCatalog(); // MySQL 返回库名PG 返回库名 String schema conn.getSchema(); // MySQL 常返回 nullPG 返回 public if (schema null dbType DbType.POSTGRESQL) { schema public; // 驱动没给就退回默认模式 } try (ResultSet rs md.getTables(catalog, schema, %, new String[]{TABLE})) { while (rs.next()) { String tableName rs.getString(TABLE_NAME); // 过滤掉生成器自己产生的中间表、版本表 if (tableName.startsWith(flyway_) || tableName.startsWith(qrtz_)) continue; tables.add(tableName); } }types参数传{TABLE}会过滤掉视图。视图该不该生成 Mapper我的意见是不该视图往往没有主键生成的写操作全是危险的而且视图结构随业务 SQL 变化不适合当作稳定契约。真要查视图手写一条 select 更合适。4.3 datasource 片段一起生成但密码必须留空工具会把连接信息回吐成一份配置片段方便贴进项目。这一步我做了个改动生成的配置里密码一律写成占位符。# 生成器输出的 datasource 片段凭据不落盘 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ${DB_USER:demo_reader} password: ${DB_PWD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 connection-timeout: 3000serverTimezone这个参数容易被忽略。MySQL 8 的驱动在不指定时区时会按 JVM 默认时区解析DATETIME容器里 JVM 时区常常是 UTC写进去的时间就比预期早八小时。生成器把时区写死在 URL 里等于把这个玄学问题堵在源头。characterEncodingutf8也不能省。少了它REMARKS读回来的中文注释就是一堆问号生成的 XML 注释全是乱码虽然不影响运行但 review 的人会以为工具坏了。4.4 多数据源场景一次生成按库分目录一个系统连两个库很常见——主库放业务表从库或另一个实例放日志表。生成器支持配置多套 datasourcedatasources: - name: main url: jdbc:mysql://127.0.0.1:3306/app_main?useUnicodetruecharacterEncodingutf8useInformationSchematrue outputDir: src/main/resources/mapper/main tablePrefix: tb_ # 生成时剥掉前缀tb_user - User - name: log url: jdbc:mysql://127.0.0.1:3306/app_log?useUnicodetruecharacterEncodingutf8useInformationSchematrue outputDir: src/main/resources/mapper/log tablePrefix: log_tablePrefix这个配置项解决的是类名问题tb_user直接转驼峰会得到TbUser剥掉前缀才是User。前缀按库配不同库的前缀习惯往往不一样。两个库生成的 XML 分目录存放各自的mapper-locations单独配互不干扰。别想着合并到一个目录里同名的UserMapper.xml撞上就是启动直接失败。5. 避坑与排查生成完跑不通的五种现场与处置5.1 报Table xxx doesnt exist但库里明明有现象本地 Windows 上生成、运行一切正常扔到 Linux 测试环境启动就报表不存在SQL 日志里表名是大写的SYS_USER。原因MySQL 在 Linux 上lower_case_table_names默认是 0表名区分大小写Windows 上默认是 1不区分。生成器如果从某些元数据字段取到了大写表名或者模板里做了toUpperCase()在 Linux 上就对不上。解决生成器里表名一律用getTables返回的原始TABLE_NAME不做任何大小写变换同时在生成前加一条自检SELECT 1 FROM 表名 LIMIT 1跑一遍跑不通就中止生成并打印实际表名。这条自检加完之后这类问题再没出现过。5.2 插入 null 时报JdbcType OTHER或类型不匹配现象某个可空字段传null时抛异常报Error setting null for parameter #5 with JdbcType OTHER或者 PostgreSQL 下报column is of type timestamp but expression is of type bytea。原因XML 里#{}没写jdbcTypeMyBatis 对null值无法推断类型退回OTHER。这是生成模板漏了字段导致的不是数据库问题。解决模板里强制每个#{}都带jdbcType。同时给映射表加兜底TYPE_NAME匹配不上任何规则时按DATA_TYPE的java.sql.Types常量再查一次两次都失败才降级为VARCHAR并打警告。我的做法是生成完成后跑一遍全量 XML 的正则扫描找出所有不带jdbcType的#{}一条都不许留。5.3 自增主键被当成普通列插入报主键冲突现象第一次插入成功第二次报Duplicate entry 0 for key PRIMARY或者严格模式下直接报Column id cannot be null。原因IS_AUTOINCREMENT没读到。部分驱动在这一列返回空串而不是YES生成器按非自增处理把id写进了 insert 列表。解决双路探测——先读IS_AUTOINCREMENT读不到就用ResultSetMetaData.isAutoIncrement()兜底见 2.2 的代码。再加一条断言如果一张表的主键只有一个字段、类型是整型、且该列有默认值或注释里带auto_increment即使探测失败也按自增处理并打警告。宁可误判成自增也不要生成一个会写坏数据的 insert。5.4 重新生成后手写的自定义 SQL 全没了现象在UserMapper.xml里加了个selectByCondition跑了一次生成器方法没了编译直接失败。原因工具整文件覆盖。这是设计问题不是 bug。解决两条路。一是把生成产物和手写 SQL 拆到两个文件——UserMapper.xml只放生成的五条基础语句UserExtMapper.xml放自定义查询两者共用同一个 namespaceMyBatis 允许同名 namespace 分散在多个文件里。二是用合并标记在生成区域外包一层注释生成时只替换标记之间的内容!-- GENERATED:BEGIN 此区域由工具生成手工修改会被覆盖 -- select idselectByPrimaryKey ... ... /select !-- GENERATED:END -- !-- 以下为手写区域生成器不会触碰 -- select idselectByCondition ... ... /select我选的是第一条路。合并标记看着聪明实际上要求生成器解析已有 XML、做 DOM 比对逻辑复杂度上一个量级而且一旦有人手动删了标记注释整个文件就废了。拆文件更笨但不会出意外。5.5 中文注释全是问号或者REMARKS一直是空现象生成的 XML 里字段注释是????或者干脆没有注释。原因有两个得分开看。乱码是连接串缺characterEncodingutf8REMARKS为空则是 MySQL 驱动默认不读表注释需要显式打开useInformationSchematrue让驱动去查information_schema而不是走SHOW命令。解决连接串里两个参数都加上useUnicodetruecharacterEncodingutf8useInformationSchematrue。加完之后第一次跑REMARKS立刻就有值了。这个参数还有个小副作用——读取元数据会多几条information_schema查询对生成器这种离线工具完全无所谓但如果你把它接到启动流程里得注意别拖慢启动。6. 接进构建dry-run 加校验和让重新生成不再心慌生成器跑通之后我用得反而少了因为心里没底——不知道这次跑会不会把某个表的结构读错会不会生成一份语义不同但看起来一样的 XML。后来加了两道保险才敢把它放进日常流程。第一道是 dry-run。所有输出先写到临时目录和现有文件做一次 diff只有确认差异符合预期才落盘。# 先空跑一遍输出到临时目录人工看 diff java -jar xmlgen.jar \ --config gen.yaml \ --dry-run \ --output /tmp/xmlgen-preview # 对比差异只看生成目录忽略时间戳行 diff -ru src/main/resources/mapper/generated /tmp/xmlgen-preview \ | grep -v ^--- | grep -v ^ | head -80--dry-run的作用是只算不写--output指定预览目录。diff 里如果出现大量result行变动但列没变八成是ORDINAL_POSITION排序不稳定回第 2 章检查主键排序。如果只是缩进变化说明模板被人改过看一眼git diff就知道。第二道是校验和缓存。把每张表的结构序列化成一段规范化 JSON——表名、列名、类型、可空、主键按列名排序——算 md5 存到.xmlgen.cache。下次生成前先比对md5 没变的表直接跳过。// 结构指纹只包含影响 XML 输出的字段顺序必须稳定 static String fingerprint(TableMeta t) { StringBuilder sb new StringBuilder(t.getTableName()).append(|); t.getColumns().stream() .sorted(Comparator.comparing(ColumnMeta::getColumnName)) // 排序保证稳定 .forEach(c - sb.append(c.getColumnName()).append(:) .append(c.getJdbcType()).append(:) .append(c.isNullable()).append(:) .append(c.isPrimaryKey()).append(:) .append(c.isAutoIncrement()).append(;)); return md5(sb.toString()); }这里排序是关键。元数据返回的列顺序理论上是ORDINAL_POSITION但不同驱动、不同版本不保证。不排序直接序列化指纹会随机变化缓存等于没有。我第一次写这函数时就漏了排序结果每次跑都全量重新生成白折腾半天。从那以后我每次改表结构都强制走一遍「dry-run 看 diff → 确认指纹变化符合预期 → 落盘」这三步再没出现过生成器把线上配置冲掉的情况。工具本身不复杂难的是让人信任它而信任只能靠这两道可验证的关卡建立起来。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站