【第二篇 · 番外 01】
【第二篇 · 番外 01】Excel 与 MySQL 双向同步
学完第二篇数据库篇,我们来做一个真实的"活":把 HR 发过来的 Excel 员工表导入 MySQL,再从 MySQL 导出一张漂亮的工资报表回给 HR。两个方向都是企业里天天要干的事,正好展示 excelOperation 库和 mariadb 驱动配合的完整闭环。
本文所有代码与输出均在仓颉 SDK 1.2.0 + MySQL 5.7 下逐行实测。
一、场景:HR 和系统的语言不通
HR 用 Excel 管人事,系统用 MySQL 存数据。两边要对上话,就要解决两个问题:
| 方向 | 典型需求 | 难点 |
|---|---|---|
| Excel → DB | HR 发过来一份新员工名单,批量入库 | 长工号不能丢精度(身份证/社保号)、日期要识别、坏行不能让整批垮掉 |
| DB → Excel | 月底要出部门工资报表、给领导看 | 大标题要好看、同部门要合并单元格、列宽要合适 |
CSV 也能做,但打开乱码(中文 UTF-8 BOM 问题)、大数字变科学计数法、不能合并单元格——这正是 .xlsx 存在的理由。本文用 excelOperation 库(零第三方依赖,纯仓颉实现)配合第二篇 07 课的 mariadb 驱动,搭一个独立 CLI 工具。
二、新建工程与依赖
cjpm init 一个可执行工程 ExcelDbCli,cjpm.toml 改成:
[package]
cjc-version = "1.2.0"
name = "ExcelDbCli"
version = "1.0.0"
output-type = "executable"
compile-option = "-Woff unused"
[dependencies]
excelApi = {git = "https://atomgit.com/wp_upala/excel_api", branch = "main"}
excelAnnotation = {git = "https://atomgit.com/wp_upala/excel_annotation", branch = "main"}
mariadb = {git = "https://gitcode.com/Cangjie-SIG/mariadb-driver.git", branch = "master"}
[target.x86_64-w64-mingw32]
[target.x86_64-w64-mingw32.bin-dependencies]
path-option = ["D:\\software\\stdx\\stdx"]
三处要注意:
- stdx 路径:
path-option指向本机 stdx 解压目录,作者机器上是D:\software\stdx\stdx(双反斜杠\\),按你机器改。 - excelAnnotation 必须显式声明,它是编译期宏包,cjpm 不允许靠传递依赖直接使用它声明的
@ExcelEntity宏。 - mariadb 地址和第二篇 07 课完全一样,如果你之前已经拉过缓存,这里不会重新下载。
compile-option = "-Woff unused"是为了压掉 mariadb 驱动和 excel 宏生成的无害 warning。
执行 cjpm update 拉齐依赖。
三、Employee 实体:用注解映射 Excel 列和 DB 列
一张表、一个实体、一次定义,双向复用:
package ExcelDbCli
import excelAnnotation.ExcelEntity
import excelAnnotation.ExcelColumn
@ExcelEntity
public class Employee {
@ExcelColumn["工号"]
private var _empNo: String; // VARCHAR(32),11 位以上不丢精度
@ExcelColumn["姓名"]
private var _name: String;
@ExcelColumn["部门"]
private var _department: String;
@ExcelColumn["入职日期"]
private var _hireDate: String; // 字符串承载 yyyy-MM-dd,方便读写两边
@ExcelColumn["工资"]
private var _salary: String; // 工资用 String,写 Excel 时不丢小数位
public init(empNo: String, name: String, department: String, hireDate: String, salary: String) {
this._empNo = empNo;
this._name = name;
this._department = department;
this._hireDate = hireDate;
this._salary = salary;
}
public mut prop empNo: String {
get() {
return this._empNo;
}
set(empNo) {
this._empNo = empNo;
}
}
public mut prop name: String {
get() {
return this._name;
}
set(name) {
this._name = name;
}
}
public mut prop department: String {
get() {
return this._department;
}
set(department) {
this._department = department;
}
}
public mut prop hireDate: String {
get() {
return this._hireDate;
}
set(hireDate) {
this._hireDate = hireDate;
}
}
public mut prop salary: String {
get() {
return this._salary;
}
set(salary) {
this._salary = salary;
}
}
}
@ExcelEntity 和 @ExcelColumn 是编译期注解宏——编译时会自动为 Employee 生成 excelFromRow(读 Excel 时用)和 excelWriter()(写 Excel 时用)。写 DB 时我们自己用 SQL 的 ? 占位符,跟第二篇 10 课的插入写法一样。
四、DB 连接 + 建表
先用 SQL 客户端把表建好(建表语句直接执行,不写进仓颉代码里):
CREATE TABLE IF NOT EXISTS employee (
emp_no VARCHAR(32) PRIMARY KEY,
name VARCHAR(50) NOT NULL,
department VARCHAR(30) NOT NULL,
hire_date DATE NOT NULL,
salary DECIMAL(10,2) NOT NULL
);
然后 DB 操作独立成一个 Db 类,静态方法方便调用:
import std.database.sql.*
import mariadb.cdbc // 必须显式 import,触发驱动静态注册
class Db {
public static func connect(): Connection {
let driver = DriverManager.getDriver("mariadb").getOrThrow()
let ds = driver.open(
"mariadb://127.0.0.1:3306",
[
("username", "root"),
("password", "wangpeng"), // 换成你自己的
("database", "tumo_governance")
]
)
return ds.connect()
}
}
一个容易踩坑的点:import mariadb.cdbc 虽然没显式调用,但必须保留——mariadb 驱动靠这个 import 的副作用把自己注册进 DriverManager,删了它再跑 DriverManager.getDriver("mariadb") 就会返回 None。
五、DB → Excel:导出带大标题和合并单元格的报表
这是给领导看的,所以要漂亮:
import std.collection.ArrayList
import std.collection.HashMap
import excelApi.ExcelUtils
class Db {
// 前面省略 connect
public static func exportExcel(path: String): Unit {
let conn = Db.connect()
let stmt = conn.prepareStatement(
"SELECT emp_no, name, department, " +
"DATE_FORMAT(hire_date, '%Y-%m-%d'), salary " +
"FROM employee ORDER BY department, emp_no"
)
let rs = stmt.query()
let list = ArrayList<Employee>()
while (rs.next()) {
let empNo = rs.get<String>(1)
let name = rs.get<String>(2)
let department = rs.get<String>(3)
let hireDate = rs.get<String>(4)
let salary = rs.get<Float64>(5).format(".2").toString()
let emp = Employee(empNo, name, department, hireDate, salary)
list.add(emp)
}
rs.close()
stmt.close()
conn.close()
// 给各列设宽
let widths = HashMap<String, Int64>()
widths.add("工号", 22)
widths.add("姓名", 12)
widths.add("部门", 14)
widths.add("入职日期", 14)
widths.add("工资", 12)
ExcelUtils.writeData(path, list, Employee.excelWriter(),
title: "员工信息表", // 第 1 行跨列合并居中的大标题
sheetName: "员工",
autoMerge: ["部门"], // 同部门相邻行自动合并
colWidths: widths,
titleRowHeight: 32.0, // 磅
headerRowHeight: 24.0)
println("导出完成:${list.size} 行 -> ${path}")
}
}
实测导出的 xlsx 文件里,row 0 是合并的"员工信息表"大标题(5 列全合并居中),row 1 是表头,row 2 起是数据,部门列相邻同值自动合并。
六、Excel → DB:导入 + 事务 upsert + 跳过坏行
HR 发过来的表可能带大标题、可能有几行脏数据、可能有重复工号需要更新——这三个坑一起处理:
import std.convert.*
class Db {
// 前面省略
public static func importExcel(path: String): Unit {
// headerRow: 1 —— 跳过大标题行,表头在第 2 行
let list: ArrayList<Employee> =
ExcelUtils.readData<Employee>(path, Employee.excelFromRow, headerRow: 1)
println("读取到 ${list.size} 行")
let conn = Db.connect()
var ok = 0
var fail = 0
let ins = conn.prepareStatement(
"INSERT INTO employee (emp_no, name, department, hire_date, salary) " +
"VALUES (?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?) " +
"ON DUPLICATE KEY UPDATE name=VALUES(name), " +
"department=VALUES(department), " +
"hire_date=VALUES(hire_date), salary=VALUES(salary)"
)
let tx = conn.createTransaction()
tx.begin()
try {
for (e in list) {
try {
ins.set(1, e.empNo)
ins.set(2, e.name)
ins.set(3, e.department)
ins.set(4, e.hireDate)
let sal = Float64.parse(e.salary)
ins.set(5, sal)
ins.update()
ok += 1
} catch (_: Exception) {
fail += 1 // 单行失败不影响其他行
}
}
tx.commit()
println("导入完成:成功 ${ok},失败 ${fail}")
} catch (ex: Exception) {
tx.rollback()
println("导入失败:${ex}")
}
ins.close()
conn.close()
}
}
headerRow 是这里的关键参数:导出时我们写了大标题(title),表头被挤到了第 2 行,所以 headerRow: 1 让库跳过第 0 行的大标题,用第 1 行当真正的表头。如果你的 Excel 没有大标题、表头就在第 1 行,这个参数可以省略(默认 0)。
七、完整 main:跑一遍完整闭环
main(): Int64 {
// 1. DB → Excel
println("=== 1. 导出 Excel ===")
Db.exportExcel("test-export.xlsx")
// 2. Excel → DB(读回刚才导出的文件,验证双向一致)
println("=== 2. 再从 Excel 导入 ===")
Db.importExcel("test-export.xlsx")
return 0
}
加上之前写的 Db.report()(按部门分组统计人数和平均工资),完整跑一遍:
=== 1. 导出 Excel ===
导出完成:5 行 -> test-export.xlsx
=== 2. 再从 Excel 导入 ===
读取到 5 行
导入完成:成功 5,失败 0
八、小结:这次学到了什么
| 技巧 | 在哪 | 为什么重要 |
|---|---|---|
@ExcelEntity + @ExcelColumn |
Employee 类 | 写注解、编译期生成映射代码,零运行时反射开销 |
headerRow: 1 跳过大标题 |
importExcel | Excel 常带标题行,读的时候必须让库知道哪一行是真正的表头 |
| mariadb 驱动的显式 import | import mariadb.cdbc |
编译器不知道这个 import 有副作用,看起来"没用"但删了就找不到驱动 |
ON DUPLICATE KEY UPDATE |
SQL | 导入时遇到已存在的主键自动更新,不用先删再插 |
什么时候用 .xlsx 什么时候用 CSV? 需要大标题、合并单元格、长数字不丢精度、日期格式——用 excelOperation;只有纯数据导出、没人直接打开——用 CSV(第一篇 30 课 GeoJSON 实战里有 CSV 写法)。
excelOperation 库的完整文档和仓库在:
- excelApi:https://atomgit.com/wp_upala/excel_api
- excelAnnotation:https://atomgit.com/wp_upala/excel_annotation
扩展方向:把 main 里的"导入/导出"改成命令行子命令(cjpm run import xxx.xlsx),接第二篇 03 课的命令行参数解析;或者挂到 22 课的 Todo REST API 上,做一个"批量导入员工"的接口。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)+ MySQL 5.7 编写。本文所有代码、导出结果与统计输出均在上述环境下逐行实测编译运行通过。如遇 SDK / 驱动版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn

浙公网安备 33010602011771号