ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

鸿蒙 Flutter 直连 MySQL:mysql_dart 适配、安全与生产实践

鸿蒙 Flutter 直连 MySQL:mysql_dart 适配、安全与生产实践 直连数据库这种架构说实话在很多团队眼里属于“民科方案”一听说移动端直接连 MySQL第一反应都是“疯了”。但我在鸿蒙应用里就是干了这件事而且干得还挺稳。这篇东西不是标题党是想把 Flutter 生态里那个纯 Dart 写的mysql_dart库怎么在鸿蒙环境里跑通、怎么保住底线安全、怎么写出能上生产的 SQL 交互逻辑完完整整地讲清楚。适合那些小程序、工具类 App、内网部署场景的开发者在做技术选型时参考也适合已经决定走直连路线、正在搜“鸿蒙 mysql_dart 适配”的同行抄作业。我要先把结论放在前面这条路能走通但前提是你要把网络边界、账号权限、连接生命周期这三件事想明白。1. 直连 MySQL 的诱惑与代价为什么非要在鸿蒙里塞一个数据库驱动先交代一下项目背景。当时我们做的是一款面向企业内部的生产数据采集应用部署环境全部在内网业务形态是数十台鸿蒙平板放在车间工位上工人通过 Flutter 写的应用录入数据。团队里没有多余的服务器资源去专门维护一套后端 API项目周期又压得很紧最直接的思路就成了客户端持有只读权限的数据库账号通过便携机的 Wi-Fi 内网直连 MySQL 实例。选择mysql_dart而不是自建后端核心原因只有一个它是纯 Dart 实现没有原生依赖理论上只要能跑 Dart 就能跑它。Flutter 在鸿蒙上的适配本来就走得曲折如果这时候再引入一个带 C 插件的数据库驱动鸿蒙侧的 NDK 编译、CMake 配置、动态库加载全是坑。而mysql_dart走的是标准 Socket 通信协议层全靠 Dart 自己解析跨平台的阻力天然就小。但这东西的代价也很明显。直连意味着数据库的 3306 端口要暴露给所有客户端任何一台设备被攻破攻击者就拿到了一个真实可用的 MySQL 会话。账号权限必须压到最低、网络边界必须卡死、连接必须加密。你们可以想象当时我把这个方案提出来运维同事的脸色有多难看。好在最后我们通过一套组合拳把风险控制住了具体怎么做的后面第四部分详细说。在动手之前我心里其实盘算过一版架构对比方案开发成本维护成本安全性适用场景自建后端 API高要写接口层高要维护服务器高可精细化控制公网应用、多端复用客户端直连数据库低跳过接口层低少一套服务低风险外溢内网工具、受限设备中间件前置代理中中中高团队有运维能力我最后选的是直连因为团队当时的真实约束就是“没有运维人力”。做技术选型最怕的是脱离约束谈理想架构如果你们有服务器资源我绝对不会劝你走这条路。2. 适配前的四层检查Flutter SDK、依赖、网络权限与 Dart 运行时2.1 Flutter 的鸿蒙 SDK 分支怎么搭mysql_dart本身不需要你改任何源码但对 Flutter 的鸿蒙支持分支是有版本要求心里要有数的。OpenHarmony SIG 维护的 flutter_flutter 仓库从 Flutter 3.7 时代就开始有可用的 HarmonyOS NEXT 适配分支。我在做这个项目时用的是 gitee 上openharmony-sig/flutter_flutter的flutter_3.13分支配合 DevEco Studio 4.0 Release 版本使用。搭建流程不复杂但是有一个坑我印象特别深默认的 pub.dev 源拉取的mysql_dart版本它的元数据里要求 Dart SDK 的版本范围必须跟你 Flutter SDK 内置的 Dart SDK 版本匹配。否则pub get那一关直接报版本冲突。我当时先flutter --version查了内置 Dart 版本再去 pub.dev 找到对应兼容的 mysql_dart 版本锁在pubspec.yaml里才把依赖装进去。environment: sdk: 3.0.0 4.0.0 dependencies: flutter: sdk: flutter mysql_dart: ^2.0.02.2 mysql_dart 依赖链条上的三个隐藏雷区版本锁好之后还要检查mysql_dart的传递依赖。它对外的核心依赖是crypto和typed_data这两个都是纯 Dart 包鸿蒙上没毛病。但如果你们用的版本较老可能还会带上archive这种包里含少量原生代码的库那种就要警惕了。第二个雷区是dart:io的可用性。mysql_dart底层连接数据库用的是dart:io的Socket和SecureSocket而鸿蒙的 Flutter 分支是基于 OpenHarmony 的 io 能力做的实现。正常情况下没问题但有一个细节鸿蒙的 DNS 解析行为与 Android 不同如果代码里直接传域名而不是 IP偶发会出现SocketException: Failed host lookup。这个后面实战环节会专门讲如何处理。第三个雷区是 SSL 证书验证。mysql_dart的ConnectionSettings支持传sslContext用的是dart:io的SecurityContext。但鸿蒙环境下系统证书库的路径和 Android 不一样如果你打算用自签证书做加密连接客户端必须内置证书文件否则握手会被优雅地拒绝。这个我在第四部分的安全设计里展开。2.3 鸿蒙应用的网络权限声明鸿蒙应用的网络权限不在 Android 的AndroidManifest.xml里配而是在工程模块的module.json5文件中声明。这件事容易被初次接触鸿蒙开发的同事忽略因为 Flutter 的 Android 目录里还有一个 manifest你改了那边鸿蒙端根本不吃。{ module: { name: entry, type: entry, // ... requestPermissions: [ { name: ohos.permission.INTERNET } ] } }不要小看这一步当时我们直接漏了这行配置结果数据库连接永远超时。mysql_dart客户端没有任何报错就是一直等在connect的 Future 上排查了半天才发现是权限没给。鸿蒙的权限模型比 Android 更严格缺权限表现出的症状往往是“静默失败”连个系统日志都不打。2.4 Dart 运行时对 IO 线程模型的约束最后一层检查是 Dart 的运行时模型。Dart 是单线程事件循环模型mysql_dart的查询操作是异步的但它内部对 Socket 的读写是同步阻塞的。换句话说如果你在 UI 线程里等一个超大查询的 Future虽然界面不会完全卡死但显微任务的执行会抢占事件循环时间片导致掉帧、触摸延迟。我的做法是把所有的数据库操作隔离到一个Isolate里跑。每个连接任务、查询任务都通过ReceivePort与主 Isolate 通信。这样做带来了第二个收益——数据库连接失败时的异常堆栈不会污染主 Isolate崩溃范围被物理隔离。Futurevoid queryInIsolate(String sql) async { final receivePort ReceivePort(); await Isolate.spawn(_dbWorker, receivePort.sendPort); final sendPort await receivePort.first as SendPort; sendPort.send(sql); }3. 握手阶段的问题清单从 TCP 探测到数据库账号匹配3.1 手动复现 MySQL 握手的排查思路mysql_dart的connect调用看似简单内部要经历 TCP 连接、协议版本协商、认证加密、字符集协商四个阶段。任何一个阶段挂了外层表现都是一个超时或者握手失败定位起来容易抓瞎。我的排查思路是先用系统工具把链路一截一截切开来验证不要一开始就怀疑三方库。第一步是在鸿蒙设备所在的局域网里用另一台机器直接戳 3306 端口。Windows 上就用Test-NetConnection 192.168.1.10 -Port 3306Linux 上用nc -vz。这一步确认网络层的通路是好的。nc -vz 192.168.1.10 3306第二步是用 MySQL 命令行客户端手动连一次确认账号和密码、host 白名单、SSL 要求这三项配置是匹配的。mysql -h 192.168.1.10 -P 3306 -u workuser -p能连通再回头检查 Dart 端代码。凡是能用命令行复现的问题一律先排除 MySQL 服务端配置凡是命令行都连不上的也别急着改代码。第三步才是改 Dart 端代码但不要只写connect要把onError回调拿全。我用的是try/catch加Timeout的组合超时时间我设的是 3 秒超过这个时间还握不上手说明链路里某个环节延迟超过预期后面大批量连接肯定会雪崩。3.2 鸿蒙设备 DNS 解析的特殊性与 IP 直连策略前面提到的 DNS 解析问题这里展开讲。鸿蒙的 Flutter 分支在处理hostname时存在一个已知的兼容性问题当系统网络从 Wi-Fi 切换到蜂窝网络或者反向切换时DNS 缓存可能没有及时刷新导致Socket.connect在同一个 hostname 上反复失败。我们的策略很朴素但也极其有效在数据库连接配置里优先使用 IP 地址而不是域名。内网环境的数据库实例 IP 本来就固定直接把 IP 填进ConnectionSettings.host绕开 DNS 解析这个不确定因素。如果你们必须用域名那就写一个简单的心跳机制检测到 Socket 异常后主动重置连接而不是无限期等待。3.3 账号 host 白名单的分段配置MySQL 的账号授权里host字段决定了允许从哪些 IP 连入。这玩意是可以分段的我当时建了三个账号账号host 段权限用途app_reader192.168.1.%SELECT常规查询、报表读取app_writer192.168.1.%SELECT, INSERT, UPDATE, DELETE数据录入、状态变更ops_admin127.0.0.1ALL运维专用禁止远程mysql_dart连接使用的账号永远只有app_reader和app_writer。绝不使用 root 或带 ALL 权限的账号连库这条是硬规矩没有任何商量的余地。权限越小即使客户端被攻破攻击面也越窄。这跟给自动驾驶系统配一个“只能踩刹车不能踩油门”的应急账号是一个道理。4. 数据库裸奔在公网的行径要杜绝直连场景的安全设计4.1 连接加密MySQL SSL 与 Dart 侧证书配置直连数据库最大的安全短板是客户端和 MySQL 之间的流量是一路明文穿过局域网的。车间里的 Wi-Fi 默认不开启 WPA2-Enterprise同一个 AP 下的其他设备只要开个抓包软件就能把你的 SQL 语句和结果集看得一清二楚。生产环境必须启用 SSL。MySQL 侧的 SSL 配置首先生成自签证书openssl req -newkey rsa:2048 -days 365 -nodes -keyout ca-key.pem -out ca-cert.pem -x509 openssl req -newkey rsa:2048 -days 365 -nodes -keyout server-key.pem -out server-req.pem openssl x509 -req -in server-req.pem -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem然后在my.cnf里启用[mysqld] ssl-caca-cert.pem ssl-certserver-cert.pem ssl-keyserver-key.pemDart 侧由于鸿蒙系统的根证书库路径不可靠mysql_dart的ConnectionSettings支持传入SecurityContext我们在里面手动加载打包进应用 assets 的服务端证书final sslContext SecurityContext.defaultContext; sslContext.setTrustedCertificatesBytes( utf8.encode(assets/certs/server-cert.pem) ); final settings ConnectionSettings( host: 192.168.1.10, port: 3306, user: app_reader, password: _readPassword(), sslContext: sslContext, sslEnabled: true, );这里有个非常重要的细节必须在 MySQL 服务端设置require_secure_transport ON或者在账号授权语句里带上REQUIRE SSL否则 MySQL 会在客户端未启用 SSL 时默默降级为明文连接。直连场景下仅靠客户端自觉开启 SSL 是不够的服务端要强制两手都要硬。4.2 密码管理的底线别把数据库密码硬编码在应用里mysql_dart的ConnectionSettings里那个password字段如果把真实密码硬编码在源码里那这个安全设计就彻底失败了。Dart 编译产物虽然会被混淆但反编译工具的字典匹配能力很强硬编码字符串一捞一个准。我们的做法是这样的数据库密码不参与 Flutter 构建产物而是由设备端从鸿蒙的 KeyStore 能力读取。设备首次开箱时管理员通过一个专用配置页录入数据库密码写入 KeyStore应用启动时从 KeyStore 拉取密码再拼装ConnectionSettings。在代码层面我会把密码的获取封装成一个单独的函数这样后面换密码的时候不用动业务代码FutureString _readPassword() async { // 从鸿蒙 KeyStore 读取敏感凭据 // 读取失败时直接抛出异常拒绝降级为任何明文方式 }4.3 SQL 注入的防范姿势预编译与白名单缺一不可直连模式下任何一条客户端主动拼接的 SQL 都是注入的温床。mysql_dart提供的execute方法如果不带参数绑定传入的内容会被直接塞进 SQL 文本。生产级写法必须走预编译。看一个反例和正例的对比// 反例直接拼接 final result await conn.execute( SELECT * FROM production WHERE line_no $lineNo ); // 正例使用 ? 占位符 参数绑定 final result await conn.execute( SELECT * FROM production WHERE line_no ?, [lineNo] );mysql_dart内部会把参数交给 MySQL 的 prepared statement 机制处理特殊字符会被正确转义从协议层面杜绝注入。即便用了预编译动态的表名、字段名仍然不能走参数绑定必须放进白名单校验。比如允许查询的设备列表字段只有id, name, production_line_no, status这几个业务代码里做映射不在 SQL 文本里拼任何用户输入。4.4 网络边界最小暴露防火墙与端口放行策略服务端的bind-address是个常被人忽略的配置项。如果 MySQL 实例部署在云端很多人忘了限制监听地址把 3306 暴露到了公网。这个必须改[mysqld] bind-address 192.168.1.10 skip-networking 0配合安全组规则只放行来源于工位网段192.168.1.0/24的 TCP 3306 端口其他来源一律拒绝。我见过不少团队把精力花在客户端加密上结果数据库端口对公网开放这属于防守姿势完全摆反了。先把网络边界收敛到最小再谈协议层的加密顺序错了全是白搭。5. 生产级 SQL 交互连接管理、预编译与事务的节奏5.1 连接池的必要性别把连接当一次性餐具在没有连接池的情况下每次查询都新建一个 TCP 连接、走一遍 MySQL 握手认证。这个握手过程在局域网内需要 20~50 毫秒如果设备一分钟内发起 10 次查询光握手开销就有 0.5 秒属于肉眼可见的浪费。而相比性能更危险的是连接数打满——MySQL 默认的max_connections是 151每台鸿蒙平板如果同时维护 5 个空闲连接20 台设备就把连接池占满了其他人全被拒之门外。mysql_dart官方没有提供内置连接池需要自己实现一个最小可用版本。我当时写了一个基于Pool的封装核心逻辑是维护一个固定数量的连接列表每次查询从列表中借一个、用完归还超时未归还的连接强制销毁重建。class MysqlPool { final ListMySqlConnection _idleConnections []; final int maxSize; MysqlPool(this.maxSize); FutureMySqlConnection getConnection() async { if (_idleConnections.isNotEmpty) { return _idleConnections.removeLast(); } if (_totalCreated maxSize) { final conn await _createNewConnection(); _totalCreated; return conn; } // 等待可用连接这里用 Completer 做请求队列 final completer CompleterMySqlConnection(); _waiters.add(completer); return completer.future; } Futurevoid returnConnection(MySqlConnection conn) async { _idleConnections.add(conn); if (_waiters.isNotEmpty) { final waiter _waiters.removeFirst(); waiter.complete(_idleConnections.removeLast()); } } }关键点有两个一是初始化时机我选择在应用刚启动时预热 2 个连接避免第一屏操作触发隐藏的握手耗时二是连接健康检查每次取连接前执行一个SELECT 1如果失败就销毁重建防止 MySQL 服务端因为wait_timeout主动断开导致客户端拿到一个坏连接。5.2 预编译语句的正确姿势与批量操作预编译不只是防注入它在性能上的收益同样可观。MySQL 执行 SQL 的流程是词法分析、语法解析、查询优化、生成执行计划、执行。预编译语句把前四步提前做好了后续执行只用换参数。mysql_dart里使用预编译的方式很简单final prepared await conn.prepare( INSERT INTO production_log (line_no, qty, operator, created_at) VALUES (?, ?, ?, NOW()) ); for (var i 0; i records.length; i) { await prepared.execute( [records[i].lineNo, records[i].qty, records[i].operator], ); } await prepared.deallocate();注意最后那个deallocate()预编译完了不释放MySQL 端会累积 prepared statement 资源一旦达到max_prepared_stmt_count上限后续预编译会直接失败。用完必须释放这是很多人拿到生产环境才踩到的坑。批量插入还能更进一步用executeMulti一次性把多条记录打包发送减少网络往返。U 型批量插入的耗时从一条一条 execute 的连续耗时降低到单次网络往返体感差距非常明显。5.3 事务的边界提交、回滚与超时处理事务是保证数据一致性的底线。在工位录数据这个场景里一次录入可能要同时更新生产记录表和库存表两步操作要么都成功、要么都失败。写事务的代码时最难的不是BEGIN和COMMIT的位置而是失败时如何把连接恢复到干净状态。我的模板是这样final conn await pool.getConnection(); try { await conn.execute(START TRANSACTION); await conn.execute(UPDATE inventory SET qty qty - ? WHERE product_id ?, [qty, productId]); await conn.execute(INSERT INTO production_log (product_id, qty) VALUES (?, ?), [productId, qty]); await conn.execute(COMMIT); } catch (e) { await conn.execute(ROLLBACK); rethrow; } finally { await pool.returnConnection(conn); }这里有个细节如果事务中途因为网络异常断开了连接ROLLBACK也不能保证执行成功。所以 finally 里归还连接前我还会用连接状态标志位做个判断一旦发现连接处于异常状态就不归还到池子里而是关闭销毁。这个逻辑不写的话坏连接会传染到后续所有事务。事务还有一个容易被忽略的问题——超时。MySQL 的innodb_lock_wait_timeout默认 50 秒如果客户端一直占着事务不提交锁等待会把业务拖垮。我在客户端侧加了 10 秒的事务超时超时直接强制回滚宁可这条数据这次没写成也不能让整个库的锁堆积起来。5.4 查询结果映射类型、时区与精度mysql_dart返回的数据类型和 Dart 的类型之间不是一一对应的。MySQL 的DATETIME默认不带时区信息直接映射到 Dart 的DateTime时如果 MySQL 服务端和设备的本地时区不同展示出来的时间会错位。我们的解决方式很粗暴SQL 里统一用UNIX_TIMESTAMP()函数取时间戳业务层再转成当地时间的DateTime。DECIMAL类型也是重灾区。MySQL 的DECIMAL(10,2)存的是定点数mysql_dart默认会把它映射成double但double在浮点运算中的精度问题会直接导致金额对不上账。生产环境的做法是金额字段在 SQL 里直接用CAST转成字符串客户端拿到字符串后自己封装一个高精度运算类或者直接用int存储“分”。SELECT product_id, CAST(price * 100 AS SIGNED) AS price_in_cents FROM products;这些坑不体现在“能连上数据库”这个层面而是体现在数据对不上、报表差几分钱排查起来极其痛苦。6. 从“能连上”到“可上线”压测表现与生产的踩坑记录6.1 一个不算严谨但足够说明问题的压测设备端建好连接池、写好预编译之后我在一台鸿蒙平板上跑了一个粗糙的压测连续执行 5000 次SELECT查询每次从 20 万行的表里取一条主键记录。结果如下场景总耗时平均单次耗时备注无连接池 无预编译43.6s8.7ms包含大量握手开销有连接池 无预编译12.1s2.4ms握手开销消失有连接池 预编译8.2s1.6ms网络往返大幅减少这个结果符合预期连接池带来的收益是数量级的预编译在此基础上再降一档以每秒约 600 次查询的速率运行设备温度没有明显上升。对于工位录入这种每秒最多操作两三次的真实场景性能富余非常充足。6.2 生产环境第一个坑wait_timeout的静默断线压测时用的连接是刚建好的没人测过“连接空闲 8 小时后还能不能用”。MySQL 服务端的wait_timeout默认 28800 秒也就是 8 小时一旦连接空闲超时服务端会把连接悄悄干掉。客户端感知不到第一次拿着这个坏连接去查询时mysql_dart会抛出一个底层 Socket 异常。我的解决方案就是在连接池的getConnection里加健康检查取连接之前先试查询一条SELECT 1失败就销毁重建。这个检查在一毫秒级别相对保底机制的收益可以忽略不计。这才是生产环境该有的防御性编程。6.3 第二个坑设备离线与重连风暴工位平板有个特点就是会时不时被工人锁屏、待机Wi-Fi 可能断掉。一旦网络恢复所有设备会同时尝试重连数据库瞬间形成重连风暴把数据库的连接线程打满。我在客户端做了两件事一是重连加随机退避退避区间在 1~5 秒随机避免设备之间的重连请求重合二是应用进入后台超过 30 秒后主动关闭所有空闲连接只在前台恢复时重建。这两个机制配合下来重连风暴基本绝迹。不要小看这种“非功能”细节生产事故往往不是主流程出的而是边缘行为堆积出来的。6.4 第三个坑日志里的 SQL 明文开发调试时我们一度在日志里直接打印带参数的 SQL 语句方便排查问题。结果日志收集系统会把 SQL 明文同步到运维平台相当于把业务数据也泄了出去。安全审计时发现这个问题我们立刻把日志里所有参数替成了脱敏符号。现在记日志只写 SQL 模板和耗时绝不打印具体参数值。7. 写给同路人直连方案的适用边界与后续扩展mysql_dart的鸿蒙适配能走通核心不是因为库本身有多神奇而是 Flutter 的跨平台能力和鸿蒙的开发工具链已经足够成熟。如果你的场景同样满足“内网部署”“设备数量可控”“查询频率低”“数据敏感度可控”这些条件直连 MySQL 完全能拿到及格分。但如果你们的应用要发布到公网下载或者设备数量上千台我劝你老老实实上后端中间件。从性能上说直连方案最大的瓶颈不在查询本身而在连接的建立与维护。把连接池写对、把生命周期管好这套方案扛住几十台设备的内网高频查询没有任何问题。最后提一个可以继续扩展的方向把mysql_dart的连接代码从 Flutter 应用层抽出来做成一个独立的鸿蒙鸿蒙端的本地服务通过系统级的分布式能力让同局域网的鸿蒙设备共享同一个连接池。这样一来账号凭据只存一份权限控制也更集中。我还没有彻底跑通这个方案如果后续实验成功再单独写一篇和大家分享。
返回列表