循序渐进学Boo - DSL篇

在《Playing with Boo's DSLs》中,Ayende用一个小实例向大家详细介绍了其DSL的实现:

OnCreate Account:
    Entity.AccountNumber = date.Now.Ticks

OnCreate Order:
    if Entity.Total > Entity.Account.MaxOrderTotal:
        BeginManualApprovalFor Entity

Ayende的主要思路就是利用Boo强大的编译器扩展特性,在编译期间将生成一个中间类,它继承于事先已定义好的抽象类;同时会把提供的DSL脚本动态编译到对应的抽象方法中。这种方法被称之为Anonymous Base Class模式。例如上述的DSL脚本-Sample.boo将会被Boo Compiler编译为:

public class Sample : DslBase
{
    public override void Execute()
    {
        this.OnCreate(typeof(Account), new __Actions_RegisterOnCreate(this.ExecuteAccount));
        this.OnCreate(typeof(Order), new __Actions_RegisterOnCreate(this.ExecuteOrder));
    }

    internal void ExecuteAccount()
    {
        (this.Entity as Account).AccountNumber = System.DateTime.Now.Ticks;
    }

    internal void ExecuteOrder()
    {
        Order rder = this.Entity as Order;
        if(order.Total > order.Account.MaxOrderTotal)
            BeginManualApprovalFor(order)
    }
}

注:上述动态生成的代码,为了展示更加直观我已做了少些不影响整体结构的修改。

为了方便大家学习减少Google的时间,我这里提供上述DSL实现的Boo代码,欢迎大家下载WritingDSLInBoo-OrenEini注:压缩包中有Ayende写的《Writing Domain Specific Languages in Boo》及实现代码。

实现的关键在于Boo CompilerStep的利用上。从我的上篇文章《循序渐进学Boo - 高级篇》可以知道CompilerStep在Boo编译期间的作用,Boo Compiler可以让开发人员与Pipeline进行交互,将自定义的CompilerStep在编译期插入到Pipeline中,从而改变最终生成的IL代码。因此我们所要做的就是根据需要自定义合适的CompilerStep,对AST进行转换,最后这些AST会产生.NET IL代码。

幸运的是,Ayende已开发了Rhino DSL工具,借助于它我们可以很方便的自定义各种Boo CompilerStep以满足项目需要,下面是我整理出来的类图:

boo_bcCS

boo_vCSboo_tfCS

借助于Rhino DSL我们所要做的就是:

1、根据需要创建合适的基类,在ImplicitBaseClassCompilerStep中会使用到它;
2、实现Rhino.DSL.DslEngine的子类。将ImplicitBaseClassCompilerStep插入到Boo Compiler的Pipeline中;
3、利用Rhino.DSL.DslFactory生成中间类的实例,从而在自己的代码中使用它。

在Rhino DSL提供的测试用例里,有大量详细的例子供大家参考,很值得一看。

 

接下来,我就结合Rhino DSL工具的使用来实现一个小例子,看看C#与Boo是如何在它的帮助下实现你自己的DSL。注:与上述例子不同的是,这里主要展现Boo在配置项上的使用。

我以前有这样一个项目:需要不定期向系统导入不同来源的用户数据(让这些用户可以使用提供的信息登陆我们的系统),由于系统对用户信息有自己的一套Membership机制且每次需要导入到系统的用户数据都有不同的格式要求,考虑到这类的情况会经常出现,为了避免重复工作,我设计开发了一个小工具来负责完成这类工作,实践证明大家的使用效果不错,很大程度上提高了工作效率。

为了方便介绍,我简化了使用到的类图:

boo_dsl

下面是工具的配置代码:

<section name="import" type="PortalUsers.ConfigSectionHandler, UserImport" />
<import>
    <user file="E:\DevTools\Data\nba.csv" assembly="PortalUsers.MxUser, UserImport" registersource="NBA" password="123456">
       <field name="name" col="2" type="key" filter="" />
       <field name="username" col="1" type="key" filter="^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$" />
       <field name="createdate" col="6" type="key" filter="" />
       
       <field name="gender"  col="4" type="custom" filter="" />
       <field name="telephone"  col="3" type="custom" filter="^\+?[0-9 ()-]+[0-9]$" />
       <field name="birthdate"  col="5" type="custom" filter="" />
    user>
    <user file="E:\DevTools\Data\sina.csv" assembly="PortalUsers.MwUser, UserImport" registersource="SINA" password="123456">
       <field name="name" col="2" type="key" filter="" />
       <field name="username" col="1" type="key" filter="^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$" />
       <field name="createdate" col="6" type="key" filter="" />
       
       <field name="gender"  col="4" type="custom" filter="" />
    user>
import>

其中类XmlConfiguration就是负责从上述配置文件里读取出需要的信息:

XmlConfiguration xml = (XmlConfiguration) ConfigurationSettings.GetConfig("import");
foreach(PortalUserInfo user in xml.PortalUsers)
{
    ...
    #region DnnUsers
    ...
    #endregion

    #region CustomUsers
    ...
    #endregion
}
我们的重点在于重构配置项(其中会有少许便于讲解的修改),首先设计出适合的DSL Script用以能够很直观表示出程序中需要用到的参数信息:
# global fields used by several import
fields:
    field @name
    field @email:
        filter = @/^\w+((-\w+)|(\.\w+))*\\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/
    field @createdate
    field @gender
    field @hhp:
        filter = @/^\+?[0-9 ()-]+[0-9]$/
    field @birthdate

# the first import
import_from @OURGAME:
    description "Import users from ourgame into database."
    provider OurgameProvider
    user_from "ourgame1.csv","ourgame2.csv"
    defaultpassword @abc123
    with_fields:
        @name
        @email
        @gender

# the second import
import_from @SINA:
    description "Import users from sina into database."
    provider SinaProvider
    user_from "sina.csv"
    defaultpassword @abcd
    with_fields:
        @name
        @email
        @hhp
        @birthdate

说明:

1、global fields used by several import

相当于定义好需要用到的全局字段信息

2、the first import与the second import

对应于不同的、需要导入的参数信息和字段信息

对于上述DSL Script内容我不做过多的解释,结合对应的XML版本,我相信大家可以明白相应的含义。利用Rhino DSL库可以很容易如下方式获取上述参数的内容:

static void Main(string[] args)
{
    DslFactory factory = new DslFactory();
    factory.Register(new PortalUserEngine());
    var bc = factory.Create("test.boo");
    bc.Prepare();
    //
    if (bc.ImportList.Count > 0)
    {
        foreach(string source in bc.ImportList.Keys)
        {
            Console.WriteLine("User from {0}", source);
            foreach (Field field in bc.ImportList[source].UserFields)
                Console.WriteLine("Field: {0}", field.Name);
            Console.WriteLine(new string('*',20));
        }
    }
    /*
    var re = new Regex("FOO", RegexOptions.IgnoreCase | RegexOptions.Compiled);
    Console.WriteLine(re.GetType().ToString());
    */
    Console.ReadLine();
}

下面我主要说说它们的DSL实现方式:

第一步:根据设计好的DSL Script,我们需要构造出合适的基类:

public abstract class PortalUserBase
{
    private ImportInfo currentImport;
    private IDictionary<string, Field> Fields = new Dictionary<string, Field>();
    public IDictionary<string, ImportInfo> ImportList = new Dictionary<string, ImportInfo>();

    public abstract void Prepare();

    [Meta]
    public static Expression fields(BlockExpression fields)
    {
        var fieldList = new ArrayLiteralExpression();

        foreach (var statement in fields.Body.Statements)
        {
            var expr = (MethodInvocationExpression)((ExpressionStatement)statement).Expression;
            var name = ((StringLiteralExpression)expr.Arguments[0]).Value;

            var field = new MethodInvocationExpression(new ReferenceExpression("Field"), new StringLiteralExpression(name));

            if (expr.Arguments.Count > 1)
            {
                // b.Statements[0]    {filter = /abc/}    Boo.Lang.Compiler.Ast.Statement {Boo.Lang.Compiler.Ast.ExpressionStatement}
                var block = expr.Arguments[1] as BlockExpression;
                var regex = ((ExpressionStatement)block.Body.Statements[0]).Expression as BinaryExpression;
                //var filter = new RELiteralExpression(regex.Right);
                field.Arguments.Add(regex.Right.CloneNode());
            }

            fieldList.Items.Add(field);
        }
        return new MethodInvocationExpression(new ReferenceExpression("AddFields"), fieldList);
    }

    public void ImportFrom(string source, Action action)
    {
        if (string.IsNullOrEmpty(source))
            throw new ArgumentException("user source cannot be null or empty.");

        currentImport = new ImportInfo();
        currentImport.RegisterSource = source;

        action();

        ImportList.Add(source, currentImport);
    }

    public void description(string text)
    {
        currentImport.Description = text;
    }

    public void UserFrom(params string[] files)
    {
        if (files.Length < 1)
            throw new ArgumentException("user source cannot be null or empty.");

        currentImport.UserFiles = files;
    }

    public void defaultpassword(string pwd)
    {
        currentImport.Password = pwd;
    }

    public void provider(Type type)
    {
        if(!typeof(IUserProvider).IsAssignableFrom(type))
            throw new ArgumentException("type supplied must be assignable to {0}.", typeof(IUserProvider).FullName);

        currentImport.Provider = Activator.CreateInstance(type) as IUserProvider;
    }

    #region Fields
    public void AddFields(Field[] fields)
    {
        foreach (Field f in fields)
            this.Fields.Add(f.Name, f);
    }
    public void UseFields(params string[] fields)
    {
        foreach (var field in fields)
            if (!this.Fields.ContainsKey(field))
                throw new ArgumentException("the field: {0} cannot be found in the defined fields.", field);
            else
                currentImport.UserFields.Add(this.Fields[field]);
    }
    #endregion
}

这是需要说明的是:

1、一般情况下,我们可以首先考虑使用Boo提供的Meta. Method来与AST进行交互。只有在复杂情况下,我们才会需要自定义AbstractAstMacro帮忙完成所需的转换。

A meta-method is a shortcut into the compiler; it is a method that accepts AST nodes and returns an AST node.

When the compiler sees a call to a meta-method, it doesn’t emit the code to call this method at runtime. Instead, during compilation, the meta-method is executed. We pass it the AST of the arguments of the method code (including anonymous blocks), and then we replace this method call with the result of calling the meta-method.

上述《Meta. Methods》中关于Meta. Method的解释相当贴切,把它的核心概念给说明白了。

上述代码中的fields就是一个Meta. Method方法,它的作用是把需要用到的全局参数field字段信息给读入到基类的Fields集合里。下面这段代码是它的核心:

return new MethodInvocationExpression(new ReferenceExpression("AddFields"), fieldList);

这里我们可以很容易看出,它最后其实是通过基类中的方法AddFields完成field的添加。fields的作用就是通过适当的AST转换完成DSL脚本中fields块内容的读取,这样我们可以用简单明了的自定义关键字来书写自己的DSL。

2、在上述实现代码中,使用了自定义的AbstractAstMacro-"With_fieldsMacro"完成DSL脚本里with_fields块内容的读取,关键代码如下:

public override Statement Expand(MacroStatement macro)
{
    var block = new Block();

    if (macro.Block != null)
    {
        var exprs = RetrieveExpressions(macro.Block);
        var mth = new MemberReferenceExpression();
        mth.Name = "UseFields";
        mth.Target = new SelfLiteralExpression(macro.LexicalInfo);
        var mie = new MethodInvocationExpression();
        mie.LexicalInfo = macro.LexicalInfo;
        mie.Arguments = exprs;
        mie.Target = mth;

        block.Add(mie);
    }
    return block;
}

这里它本质上也是调用基类方法UseFields来实现使用field的添加。 因此我们可以说只有在较复杂的AST转换上才需要使用到AbstractAstMacro,其他情况下使用Meta. Method就可以达到相同的目的。

3、DSL脚本中的description, provider, user_from, defaultpassword对于Boo而言可以仅仅看成是一次简单的方法调用,Boo中的方法调用可以不用带上两边的小括号。

4、"filter = @/^\+?[0-9 ()-]+[0-9]$/"则也是利用了Boo的语法,它可以直接在代码中使用/…/标记出正则表达式;"provider OurgameProvider"则是直接写上了实现IUserProvider接口的类,然后是通过方法provider来实例出相应对象的:

public void provider(Type type)
{
    if(!typeof(IUserProvider).IsAssignableFrom(type))
        throw new ArgumentException("type supplied must be assignable to {0}.", typeof(IUserProvider).FullName);

    currentImport.Provider = Activator.CreateInstance(type) as IUserProvider;
}

第二步:实现Rhino.DSL.DslEngine的子类PortalUserEngine:

public class PortalUserEngine : DslEngine
{
    protected override void CustomizeCompiler(BooCompiler compiler, CompilerPipeline pipeline, string[] urls)
    {
        Type baseClass = typeof(PortalUserBase);
        compiler.Parameters.References.Add(Assembly.GetAssembly(baseClass));
        pipeline.Insert(1, new ImplicitBaseClassCompilerStep(baseClass, "Prepare", "PortalUsers","System.Text.RegularExpressions"));
        pipeline.Insert(2, new UseSymbolsStep());
        pipeline.InsertBefore(typeof(ProcessMethodBodiesWithDuckTyping), new UnderscorNamingConventionsToPascalCaseCompilerStep());
    }
}

这里就是将一些自定义的CompilerStep插入到Pipeline中,我们也可以利用CompilerStep来与AST进行交互、转换。就我个人而言,我觉得一般是将较为通用、复用性高的操作放入到CompilerStep中。

ImplicitBaseClassCompilerStep:前面已提过,这是使用Rhino DSL的关键一步;

UseSymbolsStep:是将类似@name的标记转换为"name"字符;

UnderscorNamingConventionsToPascalCaseCompilerStep:顾名思义,就是将类似pascal_case的标记转换为PascalCase。

第三步:有了相应的PortalUserEngine和抽象类PortalUserBase,我们就可以使用Rhino.DSL.DslFactory来创建实例。具体的调用方式上面已经提过,这里就不累牍。

 

结束语:

这个系列终于写完,前前后后也折腾了好几个月。希望自己的文章可以对Boo感兴趣的人有所帮助。由于自己接触Boo也不够深入,肯定会有理解不正确的地方,欢迎大家斧正。最后大家以后如果对Boo有疑问的话,我推荐大家到Boo Programming Language | Google Groups里提问,里面会有很多Expert给你解答。

从一个小实例开始 - Boo开篇

循序渐进学Boo - 知识篇

循序渐进学Boo - 高级篇

循序渐进学Boo - DSL篇

 

[注]:
原先我打算使用如下的DslEngine:

public class PortalUserEngine : DslEngine
{
protected override void CustomizeCompiler(BooCompiler compiler, CompilerPipeline pipeline, string[] urls)
{
pipeline.Insert(1, new ImplicitBaseClassCompilerStep(typeof(PortalUserBase), "Prepare", "PortalUsers"));
pipeline.Insert(2, new TransformerCompilerStep(new BlockToArgumentsTransformer("ImportUser")));
}
} 

可是在最新版本的Boo基础之上,利用Rhino.DSL会出现莫名错误:
BCE0017: Boo.Lang.Compiler.CompilerError: The best overload for the method 'PortalUsers.PortalUserBase.ImportUser(*(PortalUsers.Field))' 
is not compatible with the argument list '(PortalUsers.Field, callable() as void)'. 
在跟踪Boo源代码后发现是Boo.Lang.Compiler.ICompilerStep {Boo.Lang.Compiler.Steps.MacroAndAttributeExpansion}阶段里的方法:

private void TreatMacroAsMethodInvocation(MacroStatement node)
{
MethodInvocationExpression invocation = new MethodInvocationExpression(
node.LexicalInfo,
new ReferenceExpression(node.LexicalInfo, node.Name));
invocation.Arguments = node.Arguments;
if (node.ContainsAnnotation("compound")
|| !IsNullOrEmpty(node.Body))
{
invocation.Arguments.Add(new BlockExpression(node.Body));
}
ReplaceCurrentNode(new ExpressionStatement(node.LexicalInfo, invocation, node.Modifier));
}

奇怪的是我的DSL脚本会在node.ContainsAnnotation("compound")中返回true,所以在最后的方法调用上增加了一个空方法"callable() as void"。

 

实例:

下面这些是Boo与C#混合编程的Open Source,如果大家对Boo有兴趣的话,强烈建议大家看看它们的实现方式,会很有收获:
1、mite-net 
Migrations for the .Net Framework

2、hornget
The horn package management project

1) The Decision to use Boo as the host language for the Horn internal DSL
2) The Purpose of the Horn Dsl
3) Parsing the Horn Dsl with the help of a Rhino
4) Boo compiler extensions in the Horn Dsl

3、log4net-altconf
An alternative way of configuring log4net without XML

请使用浏览器的分享功能分享到微信等