設計簡單的Script Engine

軟體需求總是不停地在改變,有些時候需求帶著UI,有些時候需求則可以排除UI,端看使用者的角色而定。會有這篇文章的原因是最近收到了一個很特別的需求,這個需求的受眾,也就是使用者其實是公司內部的PM、工程師,所以UI不一定需要很複雜,甚至不太需要UI,因為牽扯到實際商業行為,以下我便用類似的假想型需求來呈現。

  1. 這個程式要可以讀入任何文字檔,由使用者指定
  2. 讀入程式檔後,必須提供一個機制讓使用者輸入要尋找的字串
  3. 顯示找到還是沒找到。

這個需求很簡單是吧?但不到兩分鐘,需求追加。

  1. 也可以不輸入字串,那麼程式就要印出檔案內容。

沒多久,需求追加。

  1. 如果找到了,把檔案轉存到另一個地方,檔名由使用者指定。
  2. 程式必須可以批次作業,也就是可以指定多個文字檔來進行作業。

這下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。。