[.NET]使用 Regex 來 Parse 台灣的地址

  • 24227
  • 0
  • 2012-06-07

[.NET]使用 Regex 來 Parse 台灣的地址

前言

在幫同事code review的時候,看到了一段程式碼,是透過substring的方式,去拆解一整串的地址。原因是資料來源端給的地址雖然乾淨,但是是一個完整的地址字串。而要串接的服務,是需要透過郵遞區號、縣市、鄉鎮市區,來決定服務的內容。(例如物流路線)

substring的方式,不用說大家也知道,是不得以、土法煉鋼的辦法,這樣的設計雖說直覺,但相對的相當脆弱,而且針對台灣的地址,也不一定能完全滿足substring時index的規則。

所以,這個Parse地址的需求,就剛好拿來當作練習Regex的題目。(我的regular expression功力跟經驗還很差很多,如果pattern有錯,或是可以更加優化,煩請指點一下,因為我也是半寫半猜pattern符號的意義)

 

需求

需求相當明確,給一個完整地址,要能長出郵遞區號、縣市、鄉鎮市區以及剩餘的地址部分。基本上也要有功能可以直接讀取這幾個資訊串起來的完整地址,以及原始傳入的地址。

 

實作

有了需求,先來設計我們的第一個測試案例:


        [TestMethod()]
        public void AddressConstructorTest_郵遞區號5碼_parse成功_ZipCode為3碼()
        {
            string address = @"11512台北市南港區三重路66號14樓";
            Address target = new Address(address);

            var expectedCity = "台北市";
            var expectedZip = "11512";
            var expectedDistrict = "南港區";
            var expectedOthers = "三重路66號14樓";

            Assert.AreEqual(true, target.IsParseSuccessed);

            Assert.AreEqual(expectedZip, target.ZipCode);
            Assert.AreEqual(expectedCity, target.City);
            Assert.AreEqual(expectedDistrict, target.District);
            Assert.AreEqual(expectedOthers, target.Others);
            Assert.AreEqual(address, target.ToString());
            Assert.AreEqual(address, target.OrginalAddress);
        }
  1. 設計一個Address類別,在其建構式傳入原始地址。
  2. 設計相關屬性:ZipCode為郵遞區號,City為縣市,District為鄉鎮市區,Others則是剩餘的地址部分。OriginalAddress則是建構式傳入的原始地址。
  3. 覆寫Object的ToString()方法,使其將郵遞區號、縣市、鄉鎮市區與剩餘地址串起來,傳回完整的地址。
  4. 當呼叫Address建構式,建立好instance之後,即將相關資訊parse完畢,設定給相關屬性。

有了測試程式之後,接著撰寫實際的Address class。重點就只在於,Regex的pattern要怎麼設計。


    public class Address
    {
        /// <summary>
        /// 地址組成:
        /// 1.郵遞區號: 3~5碼數字
        /// 2.縣市: xx 縣/市
        /// 3.鄉鎮市區:xx 鄉/鎮/市/區
        /// 4.其他:鄉鎮市區以後的部分
        /// 規則:開頭一定要是3或5個數字的郵遞區號,如果不是,解析不會出錯,但ZipCode為空
        /// 地址一定要有XX縣/市 + XX鄉/鎮/市/區 + 其他
        /// </summary>
        /// <param name="address"></param>
        public Address(string address)
        {
            this.OrginalAddress = address;
            this.ParseByRegex(address);
        }

        /// <summary>
        /// 縣市
        /// </summary>
        public string City { get; set; }

        /// <summary>
        /// 鄉鎮市區
        /// </summary>
        public string District { get; set; }

        /// <summary>
        /// 是否符合pattern規範
        /// </summary>
        public bool IsParseSuccessed { get; set; }

        /// <summary>
        /// 原始傳入的地址
        /// </summary>
        public string OrginalAddress { get; private set; }

        /// <summary>
        /// 鄉鎮市區之後的地址
        /// </summary>
        public string Others { get; set; }

        /// <summary>
        /// 郵遞區號
        /// </summary>
        public string ZipCode { get; set; }

        /// <summary>
        /// 組成完整的地址
        /// </summary>
        /// <returns>完整的地址</returns>
        public override string ToString()
        {
            var result = string.Format("{0}{1}{2}{3}", this.ZipCode, this.City, this.District, this.Others);
            return result;
        }

        private void ParseByRegex(string address)
        {
            var pattern = @"(?<zipcode>(^\d{5}|^\d{3})?)(?<city>\D+[縣市])(?<district>\D+?(市區|鎮區|鎮市|[鄉鎮市區]))(?<others>.+)";
            Match match = Regex.Match(address, pattern);

            if (match.Success)
            {
                this.IsParseSuccessed = true;

                this.ZipCode = match.Groups["zipcode"].ToString();
                this.City = match.Groups["city"].ToString();
                this.District = match.Groups["district"].ToString();
                this.Others = match.Groups["others"].ToString();
            }
        }
    }

最原始的pattern,其實沒有這麼複雜,原始的pattern只有:(?<zipcode>\d{3,5}?)(?<city>\D+?[縣市])(?<district>\D+?[鄉鎮市區])(?<others>.+)

但碰到了幾個需求,原本的pattern無法應付:

  1. 郵遞區號應該只允許3碼或5碼,而不是3~5碼,例如4碼的話,應該排除。
  2. 有些地址可能沒有郵遞區號,但仍希望可以透過這個class幫忙拆分縣市、鄉鎮市區與其他地址部分。所以希望郵遞區號的判斷,可以變成optional的。如果不是3碼或5碼數字,則ZipCode都為空。
  3. 經過了Bill叔的提醒,有一些特別機車的鄉鎮市區要記得測試一下,因為在原本的pattern會parse錯誤。例如:台南市新市區、台南市左鎮區、桃園縣平鎮市。原因是,鄉鎮市區的資訊中,重複了鄉/鎮/市/區的字。原本的pattern會碰到第二個字就截斷。例如新市區會變成:新市。而平鎮市會變成平鎮。

最後根據這些需求,加入了相關的測試程式後,調整一下我們的pattern,改成:

(?<zipcode>(^\d{5}|^\d{3})?)(?<city>\D+[縣市])(?<district>\D+?(市區|鎮區|鎮市|[鄉鎮市區]))(?<others>.+)

簡單的說明一下:

  1. (?<name>pattern) 這樣的方式,代表了群組的概念,可以參考一下Johnny大的文章:[Regex] 進階群組建構。以上面的pattern為例,分成了4個群組,分別是zipcode, city, district與others。以city為例,(?<city>\D+[縣市]),符合這個pattern的字段,就可以透過Match的Groups["city"]取得。
  2. zipcode的部分(?<zipcode>(^\d{5}|^\d{3})?)
    (1) ^ 代表『開頭』
    (2) \d 代表『數字』
    (3) {5} 代表要『出現5次』
    (4) | 代表『或』,也就是不同條件的組合
    (5) (^\d{5}|^\d{3}) 代表『開頭需要出現5個數字,或開頭需要出現3個數字』
    (6) ? 代表 『前面的pattern應出現0或1次』

    規則說明:郵遞區號,允許『沒有』或是『出現1次』『開頭為3個數字或開頭為5個數字』的情況
  3. city的部分:(?<city>\D+[縣市])
    (1) \D 代表『非數字』字元
    (2) + 代表『出現1或多次』,\D+代表需出現『1或多個非數字字元』,且全數納入Group中
    (3) [縣市] 在pattern的最後,代表這個Group最後需要有『縣或市』其中一個字

    規則說明:地址中,郵遞區號以後,一定要有出現『縣或市』,且『縣或市』前一定要有非數字字元。當出現縣或市時,則將『郵遞區號以後,縣或市之前的字元,包含縣或市』當作city。
  4. distrcit的部分:(?<district>\D+?(市區|鎮區|鎮市|[鄉鎮市區]))
    (1) \D 代表『非數字』字元
    (2) \D+? 代表『Group中,符合條件的非數字字元,都要納入』『且以符合條件最少次為主』。注意:若只有\D+,而不是\D+?的話,則假設路名為『市中路』,因『市』也符合[鄉鎮市區]的pattern,那以『平鎮市市中路』為例的話,district就會變成『平鎮市市』。如果是\D+?,則因為已符合[鎮市],district將取得『平鎮市』。
    (3) (市區|鎮區|鎮市|[鄉鎮市區]) 在pattern的最後,代表:當出現『市區』或『鎮區』或『鎮市』這樣的字元時,則符合pattern。若沒有出現前面的字元,則最後是『鄉』或『鎮』或『市』或『區』,也符合pattern。

    規則說明:縣市之後,在『市區』或『鎮區』或『鎮市』或『鄉、鎮、市、區其中一個字』前,應出現至少一個非數字字元。才算符合district的pattern。
  5. others的部分:(?<others>.+)
    (1) . 代表『萬用字元,除了\n以外的字元都算』。
    (2) .+ 代表鄉鎮市區以後,『所有萬用字元,且至少一個萬用字元』都要納入Group中。

    規則說明:鄉鎮市區以後,一定要有非\n的字,且所有字都納入others中。

依據這些需求,所補上的test cases,就麻煩讀者朋友們自行下載sample code參考了。

 

結論

Regular expression真的很神奇,也真的一定要學會和熟用,不只要知道怎麼用,怎麼調整,還要瞭解其限制與可能造成的副作用(通常都是效能問題)。

這一篇就當作是我對Regex的敲門磚,希望自己未來可以跟Regex做好朋友 吐舌頭

 

後記

說真的,pattern大概改了10次以上,包括寫這篇文章時,還改了3次。(因為對pattern的規則符號還不夠熟悉,經驗也還不夠多)

這樣推測,我相信我上面的pattern應該還有不少改進空間,所以如果解釋錯了、說錯了、用錯了,或是有乾淨的地址在這個pattern會parse錯誤,麻煩各位前輩指點並提供一下test case給我,在這邊先謝謝了。

 

Sample code

download: AddressParsing.zip


blog 與課程更新內容,請前往新站位置:http://tdd.best/