軟體需求總是不停地在改變,有些時候需求帶著UI,有些時候需求則可以排除UI,端看使用者的角色而定。會有這篇文章的原因是最近收到了一個很特別的需求,這個需求的受眾,也就是使用者其實是公司內部的PM、工程師,所以UI不一定需要很複雜,甚至不太需要UI,因為牽扯到實際商業行為,以下我便用類似的假想型需求來呈現。
- 這個程式要可以讀入任何文字檔,由使用者指定
- 讀入程式檔後,必須提供一個機制讓使用者輸入要尋找的字串
- 顯示找到還是沒找到。
這個需求很簡單是吧?但不到兩分鐘,需求追加。
- 也可以不輸入字串,那麼程式就要印出檔案內容。
沒多久,需求追加。
- 如果找到了,把檔案轉存到另一個地方,檔名由使用者指定。
- 程式必須可以批次作業,也就是可以指定多個文字檔來進行作業。
這下UI不好設計了,因為分支太多,設計出來的UI很難符合所有需求,加上不定性的工作流程就更棘手了。
Script Engine
由於需求中可以不包含UI,因此,我們可以朝Script Engine的角度思考,也就是說讓使用者撰寫腳本,程式依據腳本內容運作,這樣就可以解決不定性工作流程與批次作業問題。要用C#做這種程式不難,只是Script的解析及相關動作的安排,這個例子會運用到許多實際的系統架構技巧,包含Execution Context、Mapping Handlers等,其實是非常有趣的例子。
我們的目的是將以下的文件解譯後執行。
Script.scp
ReadTextFile=t1.txt,data FindString=data,world,exists Print=exists |
ReadTextFile接受兩個參數,一個是文字檔案名稱,另一個是將內容放到data變數裡。
FindString接受三個參數,一個是字串內容,一個是要尋找的字串,第三個是是將結果放到exists變數裡。
Print接受一個參數,然後將這個參數的內容印出來。
我不想把問題太複雜化,所以語法上有點醜,但越簡單的需求就越能凸顯架構上的設計,如果想漂亮些的話,可以用(、)的函式符號美化,基本上就是多解析幾個字而已。
先想像一下你如何處理這個文件,接著再往下看。
The Implement
為了因應日後的需求變更,這個架構會把文件的每一行分成兩部分,一是動作、二是參數,每個動作會由特定物件處理,由該物件來決定怎麼做,這是Mapping Handlers概念,由於執行動作後的結果需要儲存,因此必須引入變數的概念,兩者合體就成了Context,下面是ActionContext的程式碼。
ActionContext.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SimpleScript
{
public abstract class ActionHandlerInfo
{
public abstract void Process(string command, Dictionary<string, object> parameters);
}
public static class ActionContext
{
static Dictionary<string, ActionHandlerInfo> _actions = new Dictionary<string, ActionHandlerInfo>();
public static void Add(string keyword, ActionHandlerInfo handler)
{
_actions[keyword.ToLower()] = handler;
}
public static void Remove(string keyword)
{
_actions.Remove(keyword.ToLower());
}
public static void ParseAndRun(string data)
{
Dictionary<string, object> contextParameters = new Dictionary<string, object>();
using (var sr = new StringReader(data))
{
while (sr.Peek() != -1)
{
var command = sr.ReadLine();
var detail = command.Split('=');
if (_actions.ContainsKey(detail[0].ToLower()))
_actions[detail[0].ToLower()].Process(detail[1], contextParameters);
}
}
}
}
}
每個Action都必須要繼承至ActionHandlerInfo並實作Process函式,ParseAndRun會依據文件的內容尋找適當的Handler來處理,如果需要變數,Action必須將變數存入contextParamters以帶入下一個Action,下面是ReadTextFile、FindString、Print三個Action的實作。
Handlers.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SimpleScript
{
public class ReadTextFileHandler : ActionHandlerInfo
{
public override void Process(string command, Dictionary<string, object> parameters)
{
var detail = command.Split(',');
var content = File.ReadAllText(detail[0]);
parameters.Add(detail[1], content);
}
}
public class FindStringHandler : ActionHandlerInfo
{
public override void Process(string command, Dictionary<string, object> parameters)
{
var detail = command.Split(',');
if (detail.Length == 3)
{
if (parameters.ContainsKey(detail[0]))
{
if (((string)parameters[detail[0]]).IndexOf(detail[1]) != -1)
parameters.Add(detail[2], true);
else
parameters.Add(detail[2], false);
}
else
throw new Exception("parse fail when use FindString, source parameter not exists.");
}
else
throw new Exception("parse fail when use FindString, parameters wrong.");
}
}
public class PrintHandler : ActionHandlerInfo
{
public override void Process(string command, Dictionary<string, object> parameters)
{
var detail = command.Split(',');
if (detail.Length == 1)
{
if (!parameters.ContainsKey(detail[0]))
throw new Exception("parse fail when use Print, source not exists in context.");
Console.WriteLine(parameters[detail[0]].ToString());
}
else
throw new Exception("parse fail when use Print, source parameter not exists.");
}
}
}
主程式如下。
Program.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SimpleScript
{
class Program
{
static void Main(string[] args)
{
ActionContext.Add("ReadTextFile", new ReadTextFileHandler());
ActionContext.Add("FindString", new FindStringHandler());
ActionContext.Add("Print", new PrintHandler());
ActionContext.ParseAndRun(File.ReadAllText("script.scp"));
Console.ReadLine();
}
}
}
Script.scp的內容如前面需求所提。
Script.scp
ReadTextFile=t1.txt,data FindString=data,world,exists Print=exists |
下面是t1.txt的內容。
T1.txt
Hello world |
執行結果。
如果把script.scp改成下面這樣。
ReadTextFile=t1.txt,data Print=data |
結果也會改變。
是不是很有趣? 如果把語法改得更漂亮,加入if或是loop概念,一個程式語言是不是慢慢成形呢? 當然,在這之前,你會先進入Token Parser的世界,不過如果真的到這地步,建議還是找現成的套件來用,會輕鬆很多,例如JavaScript.NET或是ClearScript。。