做数据采集类项目时,常见需求是:定时把采集到的数据写入 MySQL,之后还能按表、列、行查询回来。这篇文章介绍一个封装好的工具类 MySqlWriter,基于 MySqlConnector,覆盖建表、写入、事务、定时、导出、查询的完整流程。
功能说明
1. 动态建表与预编译写入
调用 Initialize 时传入表数量、每个表的列标题数组和表名数组,类内部自动生成 CREATE TABLE 语句建表。列类型提供两种重载:指定 1~255 长度的 VARCHAR,或不限长度的 TEXT。每张表会自动追加 record_time VARCHAR(20) 时间列,存放格式化后的写入时间。
建表完成后,类会为每张表预编译一条 INSERT 命令并调用 Prepare()。预编译的作用是:SQL 只解析一次,后续写入只传参数,执行更快,也避免了重复拼接 SQL 的开销。
2. 事务批量写入
Write(string[][] data) 一次写入所有表各一行数据。时间戳由方法内部统一生成,保证同一批数据时间一致。useTransaction 为 true 时,所有 INSERT 包在一个事务里提交:任何一张表写入失败,事务整体回滚,不会出现部分表写入成功的中间状态。
3. 定时采集循环
StartTimedLoop(Func<string[][]> writeFunc, CancellationToken) 每秒执行一次:取数、写入、补偿睡眠。补偿逻辑是记录本轮开始时间,写入后只睡到 1000ms,保证周期稳定,不会累积漂移。传 CancellationToken 可随时停止。
4. 表管理与 CSV 导出
DropAllTables 删除当前库全部表,删除前先关闭外键检查,结束后恢复。ExportAllTablesToCsv 把每张表导出为同名 CSV,包含列名行;字段含逗号、引号或换行时按 CSV 规范转义,NULL 导出为空字符串。
5. 三个查询方法
写入之后配套了查询能力:
-
GetAllTableNames():返回当前数据库全部表名; -
GetTableHeaders(tableName, out columnTitles, out rowTitles):返回指定表的列标题(含 record_time)和行标题(record_time 去重升序); -
GetValue(tableName, columnTitle, rowTitle):按表名、列标题、行标题定位,返回对应值。
两个关键处理
标识符安全。 表名和列名不能参数绑定,只能拼接进 SQL,有注入风险。类里用 EscapeIdentifier 按 MySQL 规则转义反引号,并在拼接前先通过 information_schema 校验表确实存在,不存在直接抛 ArgumentException。
连接并发。 MySqlConnector 的同一个 MySqlConnection 不能并发使用。定时写入和查询共用一条连接,所以三个查询方法都用 _queryLock 加锁串行执行。另外 record_time 可能重复,取值 SQL 固定 ORDER BY record_time DESC LIMIT 1,明确返回最新一条。
使用示例
cs
using var writer = new MySqlWriter("密码", "192.168.1.10", 3306, "testdb", "root");
writer.Initialize(1, new[] { new[] { "温度", "湿度" } }, new[] { "env_data" }, 50);
writer.Write(new[] { new[] { "25.3", "60" } });
writer.StartTimedLoop(CollectData, cts.Token);
完整代码约 400 行:
cs
using MySqlConnector;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace MyWpfTemplate.Tools
{
// MySQL 数据库写入工具类:支持动态建表、批量写入、预编译语句、事务提交、精确计时和 CSV 导出。
public class MySqlWriter : IDisposable
{
#region Basic
// 数据库连接字符串,构造时生成,只读。
private readonly string _connectionString;
// 数据库连接对象,初始化后可用,释放后置空。
private MySqlConnection? _connection;
// 每个表对应一个预编译 INSERT 命令。
private MySqlCommand[]? _commands;
// 标记连接、表结构和预编译命令是否已就绪。
private bool _isInitialized = false;
// 创建写入工具。
// password:数据库密码;ip:服务器 IP,空值按 localhost 处理;port:端口;database:数据库名;userName:用户名;tablePrefix:表名前缀。
public MySqlWriter(string password, string? ip, int port, string database, string userName)
{
// IP 参数可选:不传就是 localhost,传了就使用传入的 IP。
string serverIp = string.IsNullOrWhiteSpace(ip) ? "localhost" : ip;
_connectionString = $"Server={serverIp};Port={port};Database={database};User ID={userName};Password={password};";
}
#endregion
#region 初始化方法
// 初始化并指定字符串列长度;varcharLength 需在 1~255,否则抛异常。
// tableCount:表数量;titles:每个表的数据列标题,不含自动追加的 record_time;tableNames:每个表实际表名;varcharLength:VARCHAR 长度;dropExisting:是否先删表重建。
public void Initialize(int tableCount, string[][] titles, string[] tableNames, int varcharLength, bool dropExisting = true)
{
if (varcharLength <= 0 || varcharLength > 255)
throw new ArgumentException("varcharLength 必须在 1~255 之间", nameof(varcharLength));
InitializeInternal(tableCount, titles, tableNames, varcharLength, dropExisting);
}
// 初始化并使用 TEXT 列类型;参数含义同带 varcharLength 的重载。
public void Initialize(int tableCount, string[][] titles, string[] tableNames, bool dropExisting = true)
{
InitializeInternal(tableCount, titles, tableNames, 0, dropExisting);
}
// 仅建立连接,不创建表,也不预编译命令。
public void Initialize()
{
if (string.IsNullOrWhiteSpace(_connectionString))
throw new InvalidOperationException("连接字符串未正确构造");
var connection = new MySqlConnection(_connectionString);
connection.Open();
_connection = connection;
}
#endregion
#region Public
// 写入一批数据;时间戳由本方法生成并放在 data 之前。
// data:data[i] 为第 i 个表的一行数据,data[i][j] 对应 titles[i][j];useTransaction:true 时用事务包裹全部 INSERT。
public void Write(string[][] data, bool useTransaction = true)
{
if (!_isInitialized)
throw new InvalidOperationException("请先调用 Initialize 方法初始化");
// 用局部变量固定可空引用,避免后续反复解引用产生空警告。
var connection = _connection ?? throw new InvalidOperationException("数据库连接未正确初始化");
var commands = _commands ?? throw new InvalidOperationException("数据库命令未正确初始化");
if (data.Length != commands.Length)
throw new ArgumentException($"data 长度({data.Length})与表数量({commands.Length})不匹配");
// 生成写入时间戳,时间列不来自 data 参数。
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
if (useTransaction)
{
using (var transaction = connection.BeginTransaction())
{
try
{
ExecuteInserts(connection, commands, data, timestamp, transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
else
{
ExecuteInserts(connection, commands, data, timestamp, null);
}
}
// 启动每秒一次的定时采集写入循环。
// writeFunc:每次循环返回待写数据,结构与 Write 的 data 相同;cancellationToken:请求取消后循环退出。
public void StartTimedLoop(Func<string[][]> writeFunc, CancellationToken cancellationToken)
{
if (!_isInitialized)
throw new InvalidOperationException("请先调用 Initialize 方法初始化");
while (!cancellationToken.IsCancellationRequested)
{
DateTime loopStart = DateTime.Now;
// 取数并写入。
string[][] data = writeFunc();
Write(data, useTransaction: true);
// 按上一秒耗时补偿 sleep,保证周期接近 1 秒。
TimeSpan elapsed = DateTime.Now - loopStart;
if (elapsed.TotalMilliseconds < 1000)
{
Thread.Sleep(1000 - (int)elapsed.TotalMilliseconds);
}
}
}
// 释放预编译命令和数据库连接。
public void Dispose()
{
if (_commands != null)
{
foreach (var cmd in _commands)
{
cmd?.Dispose();
}
_commands = null;
}
_connection?.Dispose();
_connection = null;
_isInitialized = false;
}
// 删除当前数据库中的全部表;会先关闭外键检查,结束后恢复。
public void DropAllTables()
{
var connection = _connection;
if (connection == null || connection.State != System.Data.ConnectionState.Open)
throw new InvalidOperationException("数据库连接未打开");
// 取当前数据库名。
string dbName;
using (var cmd = new MySqlCommand("SELECT DATABASE();", connection))
dbName = (string?)cmd.ExecuteScalar() ?? throw new InvalidOperationException("未选择数据库");
// 查询 information_schema 获取所有表名。
var tableNames = new List<string>();
using (var cmd = new MySqlCommand(
"SELECT table_name FROM information_schema.tables WHERE table_schema = @db", connection))
{
cmd.Parameters.AddWithValue("@db", dbName);
using var reader = cmd.ExecuteReader();
while (reader.Read())
tableNames.Add(reader.GetString(0));
}
// 有表才执行批量删除,避免无意义开关外键检查。
if (tableNames.Count == 0)
return;
using (var cmd = new MySqlCommand("SET FOREIGN_KEY_CHECKS = 0;", connection))
cmd.ExecuteNonQuery();
foreach (var name in tableNames)
{
using var cmd = new MySqlCommand($"DROP TABLE IF EXISTS `{name}`;", connection);
cmd.ExecuteNonQuery();
}
using (var cmd = new MySqlCommand("SET FOREIGN_KEY_CHECKS = 1;", connection))
cmd.ExecuteNonQuery();
}
// 将当前数据库所有表导出为同名 CSV 文件。
// outputDir:输出目录,空值使用程序运行目录;目录需已存在。
public void ExportAllTablesToCsv(string? outputDir = null)
{
var connection = _connection;
if (connection == null || connection.State != System.Data.ConnectionState.Open)
throw new InvalidOperationException("数据库连接未打开");
// 取当前数据库名。
string dbName;
using (var cmd = new MySqlCommand("SELECT DATABASE();", connection))
dbName = (string?)cmd.ExecuteScalar() ?? throw new InvalidOperationException("未选择数据库");
// 查询当前库下所有表。
var tableNames = new List<string>();
using (var cmd = new MySqlCommand(
"SELECT table_name FROM information_schema.tables WHERE table_schema = @db", connection))
{
cmd.Parameters.AddWithValue("@db", dbName);
using var reader = cmd.ExecuteReader();
while (reader.Read())
tableNames.Add(reader.GetString(0));
}
outputDir ??= AppDomain.CurrentDomain.BaseDirectory;
foreach (var tableName in tableNames)
{
string filePath = Path.Combine(outputDir, $"{tableName}.csv");
// writer 覆盖写 CSV;cmd/reader 按表读取;using 保证释放顺序稳定。
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8))
using (var cmd = new MySqlCommand($"SELECT * FROM `{tableName}`;", connection))
using (var reader = cmd.ExecuteReader())
{
// 先写列名行。
for (int i = 0; i < reader.FieldCount; i++)
{
writer.Write(EscapeCsvField(reader.GetName(i)));
if (i < reader.FieldCount - 1) writer.Write(',');
}
writer.WriteLine();
// 再写数据行,NULL 统一导出为空字符串。
while (reader.Read())
{
for (int i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "" : reader.GetValue(i).ToString() ?? "";
writer.Write(EscapeCsvField(value));
if (i < reader.FieldCount - 1) writer.Write(',');
}
writer.WriteLine();
}
}
}
}
// 查询锁:三个查询方法与写入循环共用同一连接,必须串行访问。
private readonly object _queryLock = new object();
// 查询所有表名,按字典序返回。
public List<string> GetAllTableNames()
{
var connection = GetOpenConnection();
lock (_queryLock)
{
var tableNames = new List<string>();
using (var cmd = new MySqlCommand(
"SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name;", connection))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
tableNames.Add(reader.GetString(0));
}
return tableNames;
}
}
// 输入表名,查询所有列标题和行标题。
// tableName:表名;columnTitles:输出列标题(含 record_time,按定义顺序);rowTitles:输出行标题(record_time 去重升序)。
public void GetTableHeaders(string tableName, out List<string> columnTitles, out List<string> rowTitles)
{
var connection = GetOpenConnection();
string table = EscapeIdentifier(tableName, nameof(tableName));
columnTitles = new List<string>();
rowTitles = new List<string>();
lock (_queryLock)
{
// 先校验表存在,避免不存在或恶意的表名被拼进 SQL。
using (var cmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @name;", connection))
{
cmd.Parameters.AddWithValue("@name", tableName.Trim());
if (Convert.ToInt64(cmd.ExecuteScalar()) == 0)
throw new ArgumentException($"表不存在:{tableName}", nameof(tableName));
}
// LIMIT 0 只取列名,不读取数据。
using (var cmd = new MySqlCommand($"SELECT * FROM `{table}` LIMIT 0;", connection))
using (var reader = cmd.ExecuteReader())
{
for (int i = 0; i < reader.FieldCount; i++)
columnTitles.Add(reader.GetName(i));
}
// record_time 是固定的行标题列。
using (var cmd = new MySqlCommand(
$"SELECT DISTINCT record_time FROM `{table}` ORDER BY record_time;", connection))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
rowTitles.Add(reader.GetString(0));
}
}
}
// 输入表名、列标题、行标题,返回对应值。
// tableName:表名;columnTitle:列标题;rowTitle:行标题(record_time);返回最新一条匹配值,无匹配或 NULL 返回 null。
public string? GetValue(string tableName, string columnTitle, string rowTitle)
{
var connection = GetOpenConnection();
string table = EscapeIdentifier(tableName, nameof(tableName));
string column = EscapeIdentifier(columnTitle, nameof(columnTitle));
lock (_queryLock)
{
// record_time 可能重复,ORDER BY 明确取最新一条。
using var cmd = new MySqlCommand(
$"SELECT `{column}` FROM `{table}` WHERE record_time = @row ORDER BY record_time DESC LIMIT 1;", connection);
cmd.Parameters.AddWithValue("@row", rowTitle);
var result = cmd.ExecuteScalar();
return result == null || result == DBNull.Value ? null : result.ToString();
}
}
#endregion
#region Methods
// 校验并转义 SQL 标识符(表名/列名):空值抛异常,内部反引号按 MySQL 规则双写。
private static string EscapeIdentifier(string identifier, string paramName)
{
if (string.IsNullOrWhiteSpace(identifier))
throw new ArgumentException("标识符不能为空", paramName);
return identifier.Replace("`", "``");
}
// 检查连接已打开并返回,供查询类公共方法复用。
private MySqlConnection GetOpenConnection()
{
var connection = _connection;
if (connection == null || connection.State != System.Data.ConnectionState.Open)
throw new InvalidOperationException("数据库连接未打开");
return connection;
}
// 实际执行 INSERT;transaction 为空表示不使用事务。
// connection:已打开连接;commands:与表一一对应的预编译命令;data:待写数据;timestamp:本条写入时间。
private void ExecuteInserts(MySqlConnection connection, MySqlCommand[] commands, string[][] data, string timestamp, MySqlTransaction? transaction)
{
for (int i = 0; i < commands.Length; i++)
{
if (transaction != null)
commands[i].Transaction = transaction;
// 第 0 个参数是自动追加的 record_time。
commands[i].Parameters[0].Value = timestamp;
// 后续参数来自 data[i],顺序与 titles[i] 一致。
for (int j = 0; j < data[i].Length; j++)
{
commands[i].Parameters[j + 1].Value = data[i][j];
}
commands[i].ExecuteNonQuery();
}
}
// 内部初始化:打开连接、按需删表、校验标题、建表并预编译 INSERT 命令。
// tableCount:表数量;titles:每个表的数据列标题,不含时间戳;tableNames:每个表实际表名;varcharLength:VARCHAR 长度,0 表示 TEXT;dropExisting:是否先删表重建。
private void InitializeInternal(int tableCount, string[][] titles, string[] tableNames, int varcharLength, bool dropExisting)
{
if (string.IsNullOrWhiteSpace(_connectionString))
throw new InvalidOperationException("连接字符串未正确构造");
if (titles == null || titles.Length < tableCount)
throw new ArgumentException("titles 数组长度必须大于等于 tableCount");
if (tableNames == null || tableNames.Length < tableCount)
throw new ArgumentException("tableNames 数组长度必须大于等于 tableCount");
var connection = new MySqlConnection(_connectionString);
connection.Open();
_connection = connection;
if (dropExisting && tableCount > 0)
{
// 拼一次 DROP TABLE IF EXISTS,减少往返。
var dropSql = new StringBuilder("DROP TABLE IF EXISTS ");
for (int i = 1; i <= tableCount; i++)
{
dropSql.Append($"{tableNames[i - 1]}");
if (i < tableCount) dropSql.Append(", ");
}
dropSql.Append(";");
using (var cmd = new MySqlCommand(dropSql.ToString(), connection))
cmd.ExecuteNonQuery();
}
// 建表前先校验每个标题非空,避免生成非法 SQL。
for (int i = 0; i < tableCount; i++)
{
foreach (string title in titles[i])
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException($"第 {i + 1} 个表的标题不能为空");
}
}
// varcharLength 为 0 时使用 TEXT,否则使用指定长度 VARCHAR。
string columnType = varcharLength == 0 ? "TEXT" : $"VARCHAR({varcharLength})";
for (int i = 1; i <= tableCount; i++)
{
string[] thisTableTitles = titles[i - 1];
var colDefs = new StringBuilder();
for (int j = 0; j < thisTableTitles.Length; j++)
{
colDefs.Append($"{thisTableTitles[j]} {columnType}");
if (j < thisTableTitles.Length - 1) colDefs.Append(", ");
}
// record_time 为固定时间列,使用 VARCHAR(20) 存放格式化字符串。
string sql = $"CREATE TABLE IF NOT EXISTS {tableNames[i - 1]} (record_time VARCHAR(20) NOT NULL, {colDefs}) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;";
using (var cmd = new MySqlCommand(sql, connection))
cmd.ExecuteNonQuery();
}
_commands = new MySqlCommand[tableCount];
var paramType = varcharLength == 0 ? MySqlDbType.Text : MySqlDbType.VarChar;
for (int i = 0; i < tableCount; i++)
{
string[] thisTableTitles = titles[i];
var insertSql = new StringBuilder();
insertSql.Append($"INSERT INTO {tableNames[i]} (record_time, ");
for (int j = 0; j < thisTableTitles.Length; j++)
{
insertSql.Append(thisTableTitles[j]);
if (j < thisTableTitles.Length - 1) insertSql.Append(", ");
}
insertSql.Append(") VALUES (@t, ");
for (int j = 0; j < thisTableTitles.Length; j++)
{
insertSql.Append($"@d{j}");
if (j < thisTableTitles.Length - 1) insertSql.Append(", ");
}
insertSql.Append(")");
_commands[i] = new MySqlCommand(insertSql.ToString(), connection);
_commands[i].Parameters.Add("@t", MySqlDbType.VarChar);
for (int j = 0; j < thisTableTitles.Length; j++)
{
_commands[i].Parameters.Add($"@d{j}", paramType);
}
_commands[i].Prepare();
}
_isInitialized = true;
}
// CSV 字段转义:含逗号、引号、换行或回车时加双引号包裹,并把内部引号替换为两个引号。
// field:待转义字段;返回可直接写入 CSV 的字段文本。
private static string EscapeCsvField(string field)
{
if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
{
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
return field;
}
#endregion
}
}
单文件实现,适合采集、监控等场景直接使用或按需裁剪。