Xử lý CSV file bằng CSharp reflection

Xử lý CSV file bằng CSharp reflection

Pase CSV file với CSharp Reflection (Net.Core)

Chào các bạn, 

Dạo này trong quá trình làm việc, mình cần phải xử lý file CSV nên tích cóp được chút kinh nghiệm còm nên hôm nay viết bài để chia sẻ.

Github: https://github.com/duytq216/codelearn-blog

CSV là cái gì vậy?

CSV là một dạng file gồm nhiều hàng và cột. Thông thường thì các hàng sẽ được ngăn cách bằng dấu xuống dòng '\r\n' hoặc '\n', các cột sử dụng tab ('\t') hoặc dấu ',' để phân cách.

Điểm mạnh: Dễ đọc, dễ hiểu, dung lượng gọn nhẹ, thao tác bằng tay dễ dàng.

Điểm yếu: Không mạnh xử lý dạng chuỗi do dùng các ký tự thông thường để phân chia cột dòng, không có format chuẩn, không linh động như JSON hay XML. 

Trong bài viết này, chúng ta có file CSV với nhiều cột khác nhau, đại diện cho cái nhà (house). Application chỉ sử dụng 5 thuộc tính căn bản trong rất nhiều cột của file.

 class House
{
        public string Color { get; set; }

        public int Height { get; set; }

        public int Width { get; set; }

        public int Deep { get; set; }

        public string Name { get; set; }

        public override string ToString()
        {
            return $"Color={Color}, Height={Height}, Width={Width}, Deep={Deep}, Name={Name}";
        }
}

Phần 1: Parse file CSV theo vị trí. Cách parse đơn giản nhất.

  • Tạo class House để chứa thông tin.
  • Split theo dòng và cột để lấy thông tin.
  • Access vaue bằng cách truy xuất theo thứ tự cột.

Một ngày đẹp trời, file CSV hiện tại - version 1 bị thay đổi format,  tự dưng giữa các cột hiện có bị chèn vào thêm một số thông tin tạp nham khác, thế là cái index cho 'house.Color' không phải là vị trí đầu tiên nữa. Dĩ nhiên, application toang :)).

Để đối phó với việc này, chúng ta đơn giản cập nhật và deploy lại application :)). Ngon!

CSV version 2 đã có thể được xử lý. Vậy là ổn rồi nhỉ? Nhưng không, CSV đến từ các nguồn khác nhau, nguồn thì dùng format cũ, nguồn thì dùng format mới. Làm sao đây nhỉ? Thêm code để đếm số cột vậy! Dễ mà!

Và rồi CSV version 3 thêm cái trò bỏ các cột cũ và thêm các cột mới, thế là số lượng các thuộc tính bằng nhau. Cuộc sống thật không dễ dàng!!! Vậy thì mình lại compare header, với từng cái header thì có code parse khác nhau...

Nói đến đây, chắc các bạn hiểu sự thiếu linh động của CSV đến mức nào! Dù chúng ta chỉ cần một phần thông tin của CSV, nhưng những thông tin khác thêm vào hay bỏ ra, dễ dàng làm application hoạt động không chính xác.

Chưa kể code rất khó reuse và common hóa. Việc parse CSV xôi thịt này chán ở chỗ: Nếu có 10 file CSV khác nhau về vị trí và tên header, bạn phải tạo 10 cái function tương ứng. Nếu index = 0, set house.Name = "a" rồi nếu index =2 set house.color = "Green"...

Hãy tưởng tượng còn 1 tá các model khác: Person, Pet, Book, Car... Coi nào, tìm cách gì đi chứ!!!

Phần 2: Sử dụng C# Reflection - cho phép đọc tên, kiểu dữ liệu của các property trong một model, instance, class.

Cái khó của CSV là tự value của column, không cho phép bạn biết value đó có tên là gì, format ra sao. Đấy là điều JSON giải quyết được thông qua cú pháp của mình. Vậy còn chúng ta dùng code giải quyết làm sao? Ý tưởng là tạo một dictionary chứa thông tin về property của model ứng với vị trí column (index) trong CSV.

Bạn cần biết header ứng với index nào, sau đó từ header chuyển sang property name, cuối cùng chúng ta sẽ có một dictionary cần thiết.

Tức là:  Header name : index -> property name : index

OK! Đầu tiên, mapping header với index trước: header name - index. Kết quả không ngoài mong đợi.

Và thì tiếp theo, chuyển cái dictionary dễ thương này qua dạng: Property name : index

Để đảm bảo, tính chính xác của function, mình add thêm 1 column: "Price" ngay đầu và test lại thử lại. Như các bạn thấy, các vị trí thay đổi tương ứng theo.

Tốt rồi, giờ chúng ta đã biết, property nào đi với index nào. Chuyện còn lại khá dễ dàng. 

Với từng line trong csv:

1/ Split bằng dấu phẩy, tạo ra mảng string.

2/ Tạo ra model house và loop hết toàn bộ property name.

3/ Lấy index trong dictionary dựa vào property name.

4/ Lấy value dựa vào index ở trên.

5/ Set value vào model house và add vào list.

public static IList<House> Parse(string[] lines, Dictionary<string, int> propertyDic)
{
    //Remove header
    return lines.Skip(1).Select(l =>
    {
        var house = new House();
        var cells = l.Split(',');
        foreach (var propertyInfo in house.GetType().GetProperties())
        {
            var index = propertyDic[propertyInfo.Name];
            var rawValue = cells[index];
            var value = Convert.ChangeType(rawValue, propertyInfo.PropertyType);
            propertyInfo.SetValue(house, value);
        }
        return house;
    }).ToList();
}

Kết quả tạm ổn.

Phần 3: Thêm annotation và tổng quát hóa việc Parsing

Tuyệt vời. Thay vì đi check tên cột và set cứng vị trí từng column, nay hàm parse đã có thể mapping và tạo ra instance một cách chính xác!

Nhưng ở ví dụ trên là best case khi header name là các chữ cái viết liền và đơn giản, chúng có thể dùng như là tên property của class. Vậy giả sử header có dấu "." ngay giữa thì sao?

Vì tên của header nay đã khác với property name, nên lẽ dĩ nhiên là function parse sẽ không hoạt động. Ấy là còn dấu ".", một ký tự không được phép xuất hiện khi chúng ta đặt tên. Thế là giấc mơ dùng reflection kết thúc ở đây, lẽ nào đành quay lại cách xôi thịt trước đây...

May mắn thay, C# vẫn còn những phép màu của riêng nó, chúng ta có thể sử dụng tính năng Annotation (Attribute) để vượt qua khó khăn trên.

Nhẹ nhàng khai báo 1 class CsvColumn và mapping vào property của class House

class CsvColumn : Attribute
{
    public string Name;

    public CsvColumn(string name)
    {
        Name = name;
    }
}

class House
{
    [CsvColumn("D.color")]
    public string Color { get; set; }

    [CsvColumn("D.height")]
    public int Height { get; set; }

    [CsvColumn("D.width")]
    public int Width { get; set; }

    [CsvColumn("D.deep")]
    public int Deep { get; set; }

    [CsvColumn("D.name")]
    public string Name { get; set; }
}

Và cuộc sống vẫn đáng yêu làm sao.

Sau khi test và hiện thực no xôi chán chè, giờ là tới lúc tổng quát hóa function:

  1. Split nội dung bằng dấu xuống dòng.
  2. Dùng dòng đầu tiên để làm header hoặc truyền header từ bên ngoài vào.
  3. Tạo dictionary và chuyển đổi từ: Header name : index --> Property name : index.
  4. Loop toàn bộ các dòng để tạo ra model dựa vào dictionary ở trên.
  5. Trả về cái list model.
public static class CsvParser
{
    public static IList<T> ToModels<T>(string source, string header = null, string delimiter = ",")
        where T : new()
    {
        var lines = source
            .Split(new string[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
        var headerLine = header ?? lines.First();
        var headerNames = headerLine.Split(delimiter);

        var index = 0;
        var headerDic = headerNames.ToDictionary(k => k, v => index++);

        var propertyDic = typeof(T).GetProperties()
            .Where(info => info.GetCustomAttribute<CsvColumn>() != null
                           && headerDic.ContainsKey(info.GetCustomAttribute<CsvColumn>().Name))
            .ToDictionary(k => k.Name, v => headerDic[v.GetCustomAttribute<CsvColumn>().Name]);

        if (!propertyDic.Any())
        {
            throw new ArgumentException("Cannot mapping model and header");
        }

        return lines.Skip(header == null ? 1 : 0).Select(l =>
        {
            var model = new T();
            var cells = l.Split(delimiter);
            foreach (var propertyInfo in model.GetType().GetProperties())
            {
                if (!propertyDic.ContainsKey(propertyInfo.Name))
                {
                    continue;
                }
                var pos = propertyDic[propertyInfo.Name];
                var rawValue = cells[pos];
                var value = Convert.ChangeType(rawValue, propertyInfo.PropertyType);
                propertyInfo.SetValue(model, value);
            }

            return model;
        }).ToList();
    }
}

Thành quả.

Kết luận

Thông qua làm việc với CSV, chúng ta có cơ hội tiếp xúc với C# reflection và các tính năng tuyệt vời của nó.

Rõ ràng cách làm này chỉ giảm thiểu một phần việc deploy lại application mỗi khi CSV có sự gia tăng, giảm thiểu hay đổi vị trí các cột. Chứ nếu header name đổi thì cũng không đỡ được, nhưng được cái, bạn chỉ việc mapping model property với header name thay vì ngồi đếm vị trí của cột :)) và giảm thiểu việc parse xôi thịt cho từng loại model khác nhau.

Điểm yếu của kỹ thuật này dĩ nhiên là về performance, so với việc truy xuất trực tiếp vào giá trị của 1 cell trong csv, việc compare và convert trong lúc runtime đòi hỏi nhiều "mồ hôi" của CPU hơn. Mình cũng có thử đo về performance và thấy việc parse 10k line CSV tầm 150 millis seconds (dùng 10 cột trong tổng số 70 cột). Nếu application không quá strict về tốc độ thì các bạn có thể hoàn toàn sử dụng C# reflection :D.

Bye bye,