C#的DataTable中,没有提供分组统计的功能,因此需要对此进行功能扩展。 方法无非是两种,一种是直接利用DataTable的PrimaryKey对DataTable原始数据进行分组统计,一种是使用linq的group by功能进行统计。
使用linq的好处是代码简洁。但由于实际需求中,分组的字段是动态的,而且被统计的字段也是动态,每个被统计字段的统计算法也不同,简单的linq无法满足此实际需求。
我们假设原始的表格如下所示:
dt = new DataTable();
dt.Columns.AddRange(new DataColumn[] {
new DataColumn("A1", typeof(double)),
new DataColumn("A2", typeof(double)),
new DataColumn("A3", typeof(double)),
new DataColumn("S1", typeof(double)),
new DataColumn("S2", typeof(double)),
new DataColumn("S3", typeof(double)),
new DataColumn("S4", typeof(double))
});
dt.Rows.Add(new object[] { 1, 1, 1, 1, 1, 1, 1 });
dt.Rows.Add(new object[] { 1, 1, 1, 2, 2, 2, 2 });
dt.Rows.Add(new object[] { 1, 2, 1, 3, 3, 3, 3 });
dt.Rows.Add(new object[] { 1, 2, 1, 4, 4, 4, 4 });
dt.Rows.Add(new object[] { 1, 2, 2, 5, 5, 5, 5 });
dt.Rows.Add(new object[] { 1, 2, 2, 6, 6, 6, 6 });
dt.Rows.Add(new object[] { 1, 2, 2, 7, 7, 7, 7 });
需要进行的查询如下所示:
DataTable result = new DataTableGroupBy().GroupByLinq(dt, new string[] { "A1", "A2", "A3" },
new string[] { "S1", "S2", "S3", "S4" },
new string[] { "AVG", "MAX", "MIN", "COUNT" });
Assert.AreEqual(result.Rows[0][3].ToString(), "1.5");
Assert.AreEqual(result.Rows[0][4].ToString(), "2");
Assert.AreEqual(result.Rows[0][5].ToString(), "1");
Assert.AreEqual(result.Rows[0][6].ToString(), "2");
Assert.AreEqual(result.Rows[2][3].ToString(), "6");
Assert.AreEqual(result.Rows[2][4].ToString(), "7");
Assert.AreEqual(result.Rows[2][5].ToString(), "5");
Assert.AreEqual(result.Rows[2][6].ToString(), "3");
即以A1,A2,A3进行分组,对S1,S2,S3,S4进行统计,它们各自的统计算法均不同。
那么如何实现?
首先看如何实现对动态字段的分组。一个基本的思路是,在分组时,利用linq的key信息,把每个分组的A1,A2,A3进行字符串连接,形成一个字符串key,然后再对结果集中的key进行拆分,得到分组中的A1,A2和A3。
private string GroupData(DataRow dataRow)
{
StringBuilder builder = new StringBuilder();
builder.Remove(0, builder.Length);
foreach (string field in groupByFields)
{
builder.Append(dataRow[field].ToString() + ";");
}
string key = builder.ToString();
return key.Remove(key.Length - 1);
}
再看如何实现对动态字段的统计。在上述思路的基础上,只需要把结果集中的每个分组的item数据得到,就能够形成一个新的DataTable,对其中的统计字段,可以使用DataTable的Compute方法进行计算。Compute方法,只需要提供一个计算的表达式字符串即可,这样就可以根据输入的算法,动态生成Compute的参数。
private void FillResultStatistics(ref DataRow resultRow, DataTable temp)
{
for (int i = 0; i < statisticsFields.Length; i++)
{
string statisticsFieldCaption = GetStatisticsCaption(statisticsFields[i], methods[i]);
resultRow[statisticsFieldCaption] = temp.Compute(statisticsFieldCaption, "");
if (methods[i] == "AVG")
{
object obj = resultRow[statisticsFieldCaption];
if (obj.ToString() != "")
{
double t = Math.Round(Convert.ToDouble(obj), 2);
resultRow[statisticsFieldCaption] = t;
}
}
}
}
在此基本思路基础上,形成的完整计算过程如下:
public DataTable GroupByLinq(DataTable source, string[] groupByFields, string[] statisticsFields, string[] methods)
{
TimeSpan start = new TimeSpan(DateTime.Now.Ticks);
Init(groupByFields, statisticsFields, methods);
DataTable result = new DataTable();
AddGroupByFieldsHead(ref result);
AddStatisticsFieldsHead(ref result);
EnumerableDataRowList<DataRow> enumerableRowCollection = new EnumerableDataRowList<DataRow>(source.Rows);
Func<DataRow, String> groupingFunction = GroupData;
var groupedDataRows = enumerableRowCollection.GroupBy(groupingFunction);
foreach (var keys in groupedDataRows)
{
DataRow resultRow = result.NewRow();
FillResultGroupBy(ref resultRow, keys.Key.Split(';'));
DataTable temp = source.Clone();
ChangeDataType(ref temp);//因原始数据从csv中读出,默认都是string,所以需要转换下,否则求AVG会出异常。
foreach (var item in keys)
{
temp.ImportRow(item);
}
FillResultStatistics(ref resultRow, temp);
result.Rows.Add(resultRow);
}
TimeSpan end = new TimeSpan(DateTime.Now.Ticks);
double allTime = end.Subtract(start).Duration().TotalSeconds;
return result;
}
internal class EnumerableDataRowList<T> : IEnumerable<T>, IEnumerable
{
IEnumerable dataRows;
internal EnumerableDataRowList(IEnumerable items)
{
dataRows = items;
}
IEnumerator<T> IEnumerable<T>.GetEnumerator()
{
foreach (T dataRow in dataRows)
yield return dataRow;
}
IEnumerator IEnumerable.GetEnumerator()
{
IEnumerable<T> iEnumerable = this;
return iEnumerable.GetEnumerator();
}
}
另外一种不使用linq的方法如下。其基本思路是,遍历DataTable中的每一行,查看其PrimaryKey是否已经在结果集的DataTable中,如果没有,则在原始DataTable中,Select所有此PrimaryKey的行,对这些行进行Compute,然后把计算结果放入结果集的DataTable中。
public DataTable GroupByImp(DataTable source, string[] groupByFields, string[] statisticsFields, string[] methods)
{
Init(groupByFields, statisticsFields, methods);
DataTable result = new DataTable();
AddGroupByFieldsHead(ref result);
AddStatisticsFieldsHead(ref result);
result.PrimaryKey = CreatePrimary(result);
foreach (DataRow row in source.Rows)
{
DataRow complete = result.Rows.Find(CreateObjects(row));
if (complete == null)
{
DataRow[] rows = source.Select(CreateSelect(row));
DataTable temp = source.Clone();
for (int i = 0; i < rows.Length; i++)
{
temp.Rows.Add(rows[i].ItemArray);
}
DataRow resultRow = result.NewRow();
FillResultGroupBy(ref resultRow, row);
FillResultStatistics(ref resultRow, temp);
result.Rows.Add(resultRow);
}
}
return result;
}
其实,还可以使用另外一种思路,就是直接把csv数据,bulk insert到数据库中,直接使用select A1,A2,A3,AVG(S1),MAX(S2),MIN(S3),COUNT(S4) from a group by A1,A2,A3进行查询。在此不展开讨论。
对DataTable进行Groupby的查询,还需要满足一定的性能要求。
利用linq的性能测试结果大致如下(不能保证此性能测试结果可以在其他场景下适用):
75484行70列的DataTable(原始文件大约20.8M的csv),对2个字段进行分组,对4个字段进行统计,大约需要1.8s。
19124行70列的DataTable(原始文件大约5.41M的csv),对2个字段进行分组,对4个字段进行统计,大约需要0.6s。
可以看出,此性能不是非常的理想,因此主要看数据量大小,如果数据量控制在2w行以内,0.6s的结果,对于数据展示来讲,基本是可以接受的。
如果需要对10w以上的DataTable进行分组,建议还是先对原始数据进行Filter,再在小结果集上进行Group by。