周五的时候在写一个小程序,它的输入是电话号码,输出是该号码的区号。区号表用Access数据库存储。
解析算法的最初思路是:把整张区号表做成一个状态机,电话号码逐字符输入。这样效率应该比较高,但是要手工构造那个状态机就有点麻烦。“先让程序跑起来,然后让它快起来”——稍微简化一下,先用号码的头三位(为什么用前三位?那是因为国内区号最短就是三位)做模糊查询,过滤掉明显不匹配的区号,然后再用剩下的区号对号码进行最长子串匹配。嗯,就是这个主意。
数据库设计是酱紫滴:
RegionCode数据表
字段名 | 类型及约束 | 字段含义 |
City | 文本,非空 | 区号对应的主要城市 |
Code | 文本,主键 | 区号 |
GetCodeList查询

GetCodeList
1
SELECT Code.city, Code.code
2
FROM Code
3
WHERE Code.code like ([(@prefix] & "*")
4
ORDER BY len(code) DESC , code;
DataBaseManager封装了GetCodeList查询

DataBaseManager
1
public class DataBaseManager
2
{
3
public static void EnsureDatabaseAvailable ()
4
{ /**//*代码省略*/ }
5
6
private static OleDbConnection GetConnection ()
7
{ /**//*代码省略*/ }
8
9
public static CodeList GetCodeList(string prefix)
10
{
11
string statement= "GetCodeList";
12
OleDbDataAdapter adapter = new OleDbDataAdapter (statement, GetConnection ());
13
OleDbCommand command = adapter.SelectCommand;
14
command.CommandType = CommandType.StoredProcedure;
15
command.Parameters.Add("@Code", prefix);
16
OleDbParameter parameter = command.Parameters.Add(new OleDbParameter("@Code", OleDbType.VarWChar, 255));
17
parameter.Value = prefix;
18
DataTable table = new DataTable("Code");
19
adapter.Fill(table);
20
return new CodeList(table);
21
}
22
}
查询结果被传递给CodeList类,它封装了对结果集的遍历(这个类还会有其他方法,例如提供区号和城市之间的映射查询)

CodeList
1
public class CodeList : IEnumerable
2
{
3
ArrayList mList = new ArrayList();
4
5
internal CodeList(DataTable table)
6
{
7
foreach (DataRow row in table.Rows)
8
{
9
mList.Add(row[0]);
10
}
11
}
12
13
public IEnumerator GetEnumerator()
14
{
15
return mList.GetEnumerator();
16
}
17
}
接下来是解析区号的RegionCodeParser类——本来它没必要是个类,但考虑到以后可能会替换算法,还是这么做了:

RegionCodeParser
1
public class RegionCodeParser
{
2
3
/**//// <summary>
4
/// 从一个电话号码中解析出区号
5
/// </summary>
6
/// <param name="phoneNumber">待解析的电话号码</param>
7
/// <param name="defaultValue">缺省值</param>
8
/// <returns>解析出的区号;如果不含区号,返回缺省值</returns>
9
public string Parse (string phoneNumber, string defaultValue)
10
{
11
CodeList list = DataBaseManager.GetCodeList (phoneNumber.Substring(0, 3));
12
foreach (string code in list)
13
{
14
if (phoneNumber.IndexOf(code) >= 0) return code;
15
}
16
return defaultValue;
17
}
18
}
程序的流程大致是:
接受一个电话号码和->取出前三位作为参数调用数据库查询GetCodeList,获得记录集->遍历记录集,用每一个区号去匹配电话号码,直到穷尽或找到匹配区号(分别返回缺省值或者匹配区号)。
接下来是单元测试:

RegionCodeParserTestCases
1
[TestFixture]
2
public class RegionCodeParserTestCases
3
{
4
[SetUp]
5
public void SetUp ()
6
{
7
DataBaseManager.EnsureDatabaseAvailable();
8
}
9
10
[Test]
11
public void SmokeTest ()
12
{
13
const string Default = "999";
14
string[] phoneNumbers = new string[]
{ "02000000000", "02100000000", "59910000" }; //为保护客户隐私,电话号码已作马赛克处理,特此声明
15
string[] regionCodes = new string[]
{ "020", "021", Default };
16
17
using (RegionCodeParser parser = new RegionCodeParser())
18
{
19
for (int i = 0; i < phoneNumbers.Length; i ++)
20
{
21
string code = parser.Parse(phoneNumbers[i], Default);
22
Assert.AreEqual(regionCodes[i], code);
23
}
24
}
25
}
26
}
顺利通过编译->执行测试用例->OMG,被NUnit狠狠踩了一脚煞车——Red bar~~
NUnit给出的提示是:
Parser.UnitTest.RegionCodeParserTestCases.SmokeTest :
String lengths are both 3.
Strings differ at index 0.
expected:<"020">
but was:<"999">
-----------^
RegionCodeParserTestCases.SmokeTest方法中for循环底部的Assertion失败了。 看上去第一个数据都没能通过测试。
打个log检查一下,CodeList居然是空的!我没看错吧?揉揉眼睛——在Access里面测试得好好的。难道是参数传递的问题不成?尝试改变参数的类型和长度,毫无效果。早就听说Access的查询对Ado.net支持很差,难道是真的?好吧,我认输,换条路,GetCodeList查询的过滤条件不用like了,改成这样:

Revision: GetCodeList
1
SELECT Code.city, Code.code
2
FROM Code
3
WHERE ((left(Code.code,len([@prefix]))=[@prefix]))
4
ORDER BY len(code) DESC , code;
NUnit再测,还是Red bar……
OK,再退一步,我现在已经完全不信任Access的查询了,我自己拼SQL,参数传递我也一样不信任,直接把参数拼进SQL串去:

DataBaseManager.GetCodeList
1
public static CodeList GetCodeList(string prefix)
2
{
3
const string statement =
4
"SELECT Code.city, Code.code FROM Code" +
5
" WHERE ((left(Code.code,len([@prefix]))=[@prefix])) " +
6
"ORDER BY len(code) DESC , code;";
7
statement = statement.Replace("@prefix", prefix);
8
statement = statement.Replace("@prefix", prefix);
9
OleDbDataAdapter adapter = new OleDbDataAdapter (statement, (OleDbConnection)connection);
10
OleDbCommand command = adapter.SelectCommand;
11
command.CommandType = CommandType.StoredProcedure;
12
command.Parameters.Add("@Code", prefix);
13
OleDbParameter parameter = command.Parameters.Add(new OleDbParameter("@Code", OleDbType.VarWChar, 255));
14
parameter.Value = prefix;
15
DataTable table = new DataTable("Code");
16
adapter.Fill(table);
17
return new CodeList(table);
18
}
再测,依然Red bar……
别急,擦擦汗,验证一下SQL串的正确性——拼完串的地方加个断点->添加快速监视->粘出拼好的SQL,放在Access里面执行——见鬼了,查询结果完全没问题:
为啥程序都查不到呢?难道Access对Ado.net的支持差到这种地步吗?
最后一招,关门放狗……googling到一些文字,说Access不支持命名参数传递,只能严格按照参数出现的位置顺序传递实参。可是,我用SQL串拼得完全不需要参数呀?而且GetCodeList只有一个参数,完全不存在顺序问题啊?
光阴似箭,日月如梭,半天时间已经报销了,还是毫无进展。休息一下先,到走廊上溜达溜达……
再次回到座位上,老老实实从头看一遍程序。突然一行代码从眼前闪过:

CodeList constructor
1
internal CodeList(DataTable table)
2
{
3
foreach (DataRow row in table.Rows)
4
{
5
mList.Add(row[0]);
6
}
7
}
这里我偷了个懒,用索引代替字段类型,这是一个隐含的假设:结果集的第一个字段是区号。查一下SQL,OMG!第一个字段是city,我违反了自己的诺言。天哪!一个业余程序员才会犯的错误居然花了我半天宝贵的时间?!
痛定思痛,痛何如哉?总结教训,以为殷鉴:
1、在怀疑别人是否出了问题之前,先确定自己是正确的。
2、每个单元测试用例的范围应该尽量小,以便快速定位问题。
3、尽量避免“按约定编程”,这会给程序加入容易使人忽略的假设,难以发现和定位。