跟傳統系統打交道時,XML、JSON等格式多半無用武之地,往往得透過"固定欄寬資料格式"進行資料交換。

在撰寫程式解析固定欄寬資料時,有幾點注意事項:

  1. 欄位寬度計算與中文編碼有關,實務上使用BIG5編碼還是大宗(阿公級系統很少能支援Unicode)。
  2. BIG5編碼中,半形英數字佔一個Byte,全形符號及中文佔兩個Byte,欄寬規格書中CHAR(n),n多指Bytes,所以計算長度時,需把握中文算2或英數字算1的原則。
  3. 建議不要再用比對第一個Byte ASCII的方法自己判別全半形/英數/中文,請愛用Encoding.GetEncoding(950)(或Encoding.GetEncoding("big5"))的現成方法。
  4. 由於Unicode裡中英文的長度都算1,與BIG5中文計2英數算1有差別,故無法直接用SubString()精準截取預期位置的字串。建議做法是先GetBytes()轉成byte[]後,一一計算各欄位置及長度再用Encoding.GetString(byte[], startPos, length)取回內容。

以下是簡單範例,假設某BIG5編碼固定欄寬資料檔有四個欄位,分別是姓名(10)、註冊日期(8)、電話(11)、備註(10) (括號內為各欄長度),程式中示範如何利用Encoding.GetBytes() + Encoding.GetString(byte[], startPos, len)技巧一一取出各欄位內容。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
 
namespace Tester
{
    class Program
    {
        static string rawText =
@"姓名      註冊日期電話       備註      
Jeffrey   197001010800092000 人員      
黑暗執行緒200502010988123456 部落格名稱
中文ABC   20101201021234567  無        ";
        static void Main(string[] args)
        {
            //入門玩法
            string[] lines = rawText.Split('\n');
            Encoding big5 = Encoding.GetEncoding(950);
            foreach (string line in lines)
            {
                byte[] data = big5.GetBytes(line.TrimEnd('\r'));
                string name = big5.GetString(data, 0, 10).Trim();
                string regDate = big5.GetString(data, 10, 8).Trim();
                string tel = big5.GetString(data, 18, 11).Trim();
                string remark = big5.GetString(data, 29, 10).Trim();
                Console.WriteLine("{0},{1},{2},{3}", 
                    name, regDate, tel, remark);
            }
            Console.ReadLine();
            
            //進階玩法
            FixedWidthTextParser fwtp = new FixedWidthTextParser(big5, 
                new FieldDef("姓名", 10), new FieldDef("註冊日期", 8),
                new FieldDef("電話", 11), new FieldDef("備註", 10));
            foreach (var dict in fwtp.Parse(rawText))
            {
                Console.WriteLine("{0},{1},{2},{3}",
                    dict["姓名"], dict["註冊日期"], dict["電話"], dict["備註"]
                    );
            }
            Console.ReadLine();
 
        }
    }
}

不過在實務經驗中,用一串Encoding.GetString(byte[], startPos, len)取出各欄位有個小困擾: 因前一欄長度將決定下一欄的startPos,當欄位數較多時,常常得靠心算或敲計算機計算各欄起始位置(要命的是這種小木頭都會的加法練習,我還老算錯...),而且一旦有欄位長度調整或是順序調動,就有一堆startPos得重算...

上述程中示範的進階玩法,這是我後來較愛用的做法。宣告一個通用的固定欄寬文字解析類別,建立時定義各欄位名稱與長度,接著送入文字內容,就能用Dictionary["欄位名稱"]取用解析後的各欄內容,若要修改欄位長度或順序,只需修改建構式中的欄位宣告,不用再苦哈哈重算startPos,很方便吧!

以下是FixedWidthTextParser程式範例,未來遇有固定欄寬文字解析時,不妨試試這種新做法。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
 
/// <summary>
/// 欄位定義
/// </summary>
public class FieldDef
{
    /// <summary>
    /// 欄位名稱
    /// </summary>
    public string FieldName;
    /// <summary>
    /// 起啟位置
    /// </summary>
    public int StartPos = -1;
    /// <summary>
    /// 欄位長度
    /// </summary>
    public int Length;
    /// <summary>
    /// 建構式
    /// </summary>
    /// <param name="fldName">欄位名稱</param>
    /// <param name="len">欄位長度</param>
    public FieldDef(string fldName, int len)
    {
        FieldName = fldName;
        Length = len;
    }
}
/// <summary>
/// 固定寬度文件解析
/// </summary>
public class FixedWidthTextParser
{
    List<FieldDef> fields = new List<FieldDef>();
    Encoding encoding;
    int lineLength = 0;
    /// <summary>
    /// 建構式,傳入文件定義
    /// </summary>
    /// <param name="enc">文字編碼</param>
    /// <param name="def">欄位定義</param>
    public FixedWidthTextParser(
        Encoding enc, 
        params FieldDef[] def)
    {
        encoding = enc;
        int startPos = 0;
        foreach (FieldDef fd in def)
        {
            fd.StartPos = startPos;
            fields.Add(fd);
            startPos += fd.Length;
            lineLength += fd.Length;
        }
    }
    /// <summary>
    /// 傳入單行資料字串,解析為多欄值
    /// </summary>
    /// <param name="line">原始單行資料字串</param>
    /// <returns>各欄位值之雜湊表</returns>
    public Dictionary<string, string> ParseLine(string line)
    {
        //資料字串轉為byte[]
        byte[] data = encoding.GetBytes(line);
        //檢查長度是否吻合?
        if (data.Length != lineLength)
            throw new ApplicationException(
                string.Format("字串長度({0}Bytes)不符要求({1}Bytes)!", 
                data.Length, lineLength));
        //宣告雜湊結構存放資料
        var result = new Dictionary<string, string>();
        //依欄位位置、長度逐一取值
        foreach (var fd in fields)
            result.Add(fd.FieldName, //由指定位置取得內容並截去尾端空白
                encoding.GetString(data, fd.StartPos, fd.Length).Trim());
        return result;
    }
    /// <summary>
    /// 解析多行固定欄寬資料
    /// </summary>
    /// <param name="text">多行文字資料</param>
    /// <returns>解析結果</returns>
    public List<Dictionary<string, string>> Parse(string text)
    {
        var all = new List<Dictionary<string, string>>();
        using (StringReader sr = new StringReader(text))
        {
            string line = null;
            while ((line = sr.ReadLine()) != null)
                all.Add(ParseLine(line));
        }
        return all;
    }
    /// <summary>
    /// 解析固定欄寬資料檔
    /// </summary>
    /// <param name="path">檔案路徑</param>
    /// <returns>解析結果</returns>
    public List<Dictionary<string, string>> ParseFile(string path)
    {
        if (!File.Exists(path))
            throw new ApplicationException(path + "不存在!");
        return Parse(File.ReadAllText(path, encoding));
 
    }
}

Comments

# by 小熊子

太好了,可以再許個願嗎? 之前專案的需求,除了解析文字檔外,還需要將資料轉出成固定長度,有的時候文字要補空白,數字要補0,有的要切掉中文字,有的要置右對齊…

Post a comment


55 - 17 =