65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 F# 创建 DSL

starIconstarIconstarIconstarIconstarIcon

5.00/5 (50投票s)

2009 年 8 月 17 日

CPOL

9分钟阅读

viewsIcon

94867

downloadIcon

510

让我们使用 F# 创建一个简单的项目估算 DSL!

目录

引言

如果你和我一样,你已经厌倦了人们到处使用“DSL”这个词,却不展示一个如何实现它的好例子以及在哪里使用它——更不用说给出一个体面、人类可读的 DSL 描述,而不涉及无关的概念(我主要指的是像 Oslo 或 MPS 这样的东西)。

好的,DSL 到底是什么?DSL 是一种使用英语而不是编程语言来定义领域特定(你可以说是行业特定,但也可以更狭窄)逻辑的方式。显而易见的优点是,非技术人员可以编辑 DSL,而无需担心花括号、分号之类的东西。

在本文中,我将展示如何使用 F# 编程语言创建一个简单的 DSL,以帮助进行软件估算。仅供您参考,这是一个真实生活中的例子,我们公司正在使用一个更复杂的 DSL 版本。好了,我们开始吧!

我先说清楚,本文不提供教科书式的 F# 代码,原因是这真的无关紧要,因为 F# 代码是我们的最终产品。我可以想到十几种改进所呈现的 F# 代码的方法,但如前所述,这并非本次练习的目的。

源代码

附带的代码是一个单独的 * .fs 文件,因为我可能会因为发布 VS2010 解决方案而被“鞭打”。我希望你知道如何处理它。要运行它,你需要在你的机器上安装 Project 2007——否则它将无法工作。祝你好运!

问题陈述

当有人想要编写软件时,他们通常会联系一家软件开发公司,提供一份称为 RFP(“提案请求”的缩写)的文件。根据这份 RFP 的详细程度,代码商店可以进行详细估算,或者给出一个大概的数字。除非客户想要专用团队或有模糊的需求,否则代码商店的估算是一个固定价格的项目时间表。是的,我知道这听起来不太敏捷,但在客户进行了大量前期设计的情况下,这实际上是说得通的。

总之,有人必须进行估算,即:将项目分解为任务,给出持续时间,将资源(=人)分配给任务,定义里程碑,等等。这种估算可以在 Microsoft Project 等程序中完成——对我们来说是个不错的选择,因为从 F# 应用程序自动化 Office 很容易。

那么为什么还需要 DSL 呢?嗯,老实说,你大概可以用绘制甘特图所需时间的十分之一来完成估算,而无需进行 Project 的所有重排和点击操作。不仅如此,你通常还需要应用某种逻辑(即动脑子,呃!)来确保项目计划是良好均衡的(我指的是资源利用率等)。拥有 DSL 意味着你可以优化 *并且* 自动生成计划。通过 DSL,你可以将尽可能多的业务逻辑融入你的规划过程中——例如,如果你的公司有一个流程数据库(顺便说一句,这是 CMMI 4 级左右的),你可以尝试根据经验数据验证计划。

你对 DSL 的估算想法心动了吗?如果不,这里还有另一个好处:集成。你可以将 Project 与其他系统集成,以从现有数据中获得更多价值。例如,如果你运行 Dynamics CRM,你可以调整特定客户的资源定价,以便将更好的开发人员分配给 *他们* 认为重要的功能。这一切听起来都非常浮夸和 BI 式,但这是一家代码商店的生活。

最终用户

在大多数代码商店中,除少数例外,估算由项目经理 (PM) 完成。这些人有时是技术人员,有时不是,所以你不能指望他们懂编程。但你绝对可以指望他们能够使用 DSL,然后按下某个神奇的按钮来生成项目计划,或者以其他方式将他们的 DSL 涂鸦纳入估算 BI 场景。

持续流程改进

我冒昧地说一句,如果你一直在改进(比如说)你的估算 DSL,并对其进行定制,这将是改进你的业务流程的绝佳机会。想想看——作为一家代码商店,你可以利用闲置的开发资源来提高业务效率。这很棒,不是吗?反正我觉得是。

选择一门语言

问题解决了,让我们考虑一个解决方案。你当然可以创建一个自由格式的 DSL 语言并编写一个解析器,但这有点乏味。一个更简单的解决方案是使用一种看起来像英语的编程语言(这里没有文化偏见——你可以随意使用日语或任何其他语言),这样最终用户就不会知道区别。当然,一些语言的语法 *会* 渗透到 DSL 中,但其程度有所不同。

DSL 的流行语言包括 Boo(Ayende 正在努力推广它)、Ruby 和 F#。Boo 非常强大,对于需要元编程支持的情况(*不是* 我们的场景),它很棒。Ruby 我一窍不通,所以不予评论。现在,F# 是一门流行的语言,也是 .NET 基础架构的一等公民(尤其是在 VS2010 之后)。所以,我们将着眼于创建一个基础设施,让项目经理可以轻松地编写项目估算。

我先声明一下:F# 是一种面向不可变性的语言,在使用像项目这样可以累积任务或里程碑的高度可变概念时,它看起来有点奇怪。这种奇怪之处可以通过用 C# 编写 DSL 数据结构然后在 F# 中使用它们来轻松弥补。但是,在这个例子中,我将只使用 F#。

第一个 DSL 语句

我将尽量保持简单。让我们从定义一个带有名称和开始日期的项目开始。

project "Write F# DSL Article" starts_on "16/8/2009"

上面是一个完全合法的 F# 语句。它基本上是对一个名为 `project` 的函数进行的函数调用,该函数接受 3 个参数。第一个参数是项目名称——不,除非你想自己解析,否则你无法在这里省略引号。第二个参数是一个占位符——一个值无关紧要的常量;它在这里的唯一目的是使规范具有可读性和 BDD 风格。你可以随意将 `starts_on` 关键字扩展为两个独立的部分,但我更喜欢不过度,尤其是在有可能出现关键字的情况下。第三个参数是以字符串形式表示的项目开始日期。

信不信由你,DSL 使用 OOP 构造来管理构造的项目。例如,我们有一个 `Project` 类,它是我们 DSL 中项目的表示。下面会展示。提前跳过一些内容,请确保你的类型不与其他程序集的类型冲突。毕竟,Microsoft Project 程序集可能也有一个 `Project` 类型。

type Project() =
  [<DefaultValue>] val mutable Name : string
  [<DefaultValue>] val mutable Resources : Resource list
  [<DefaultValue>] val mutable StartDate : DateTime
  [<DefaultValue>] val mutable Groups : Group list

我之前警告过奇怪的语法,对吧?上面是 F# 定义公共字段的方式。你还会注意到我使用 `list` 类型而不是 `System.Collections.Generic` 类型。只要能工作,DSL 使用什么类型真的不重要。

我们的 DSL 只支持一个项目,这个项目将处于“全局作用域”,可以说。

>let mutable my_project = new Project()

这里的命名约定有点随意,除了我们在规范的末尾需要一个语句来实际执行某些操作,而 `my_project` 是该操作的一个不错的标识符名称。但是,现在,我们可以终于展示前面提到的 `project` 语句了。

let project name startskey start =
  my_project <- new Project()
  my_project.Name <- name
  my_project.Resources <- []
  my_project.Groups <- []
  my_project.StartDate <- DateTime.Parse(start)

就这样。我可能已经揭示了 90% 的 DSL 构建是什么样子。你现在可以关闭这篇文章去探索了,因为你已经知道所有内容是如何工作的。在本文的其余部分,我将展示一些 F# 的实现细节。

处理列表

项目中的工作是由 *资源* 完成的(不是很令人满意,对吧?)。资源是指特定的人(“John”)以及特定的职位(“初级 DBA”)和特定的时薪(65 美元)。项目通过一个 `Resource list` 来引用资源(看,F# 是人类可读的)。让我们看看 `Resource` 的定义。

type Resource() =
  [<DefaultValue>] val mutable Name : string
  [<DefaultValue>] val mutable Position : string
  [<DefaultValue>] val mutable Rate : int

我再次滥用了 F#,但至少这个结构很容易处理。现在,资源定义也是我们 DSL 的一部分,可能如下所示。

>resource "Dmitri" isa "Project Manager" with_rate 140

上述语句采用了与 `project` 相同的技巧,但它作用于一个已经存在的全局变量 `my_project`。

let resource name isakey position ratekey rate =
  let r = new Resource()
  r.Name <- name
  r.Position <- position
  r.Rate <- rate
  my_project.Resources <- r :: my_project.Resources

资源和我们使用的所有其他列表最终都以 *反向顺序* 列出。但这没关系——我们在必要时会反转它们。如果你不喜欢,可以使用 `List`。

使用字符串进行引用

我接下来想介绍的 DSL 概念是任务的 *组*。一个任务组通常由一个人来维护,以便保持,嗯,认知连贯性。我们定义一个组如下。

>group "Project Coordination" done_by "Dmitri"

为了更好地理解,让我们看看 `Group` 类。

type Group() =
  [<DefaultValue>] val mutable Name : string
  [<DefaultValue>] val mutable Person : Resource
  [<DefaultValue>] val mutable Tasks : Task list

一个组引用一个特定的资源,而我们的 DSL 仅将其指定为字符串。有问题吗?我认为没有。

let group name donebytoken resource =
  let g = new Group()
  g.Name <- name
  g.Person <- my_project.Resources |> List.find(fun f -> f.Name = resource)
  my_project.Groups <- g :: my_project.Groups

请注意,与 LINQ 不同,我们在搜索正确的资源后不必调用 `Single()`。

更高的流畅性

最后但同样重要的是,我们定义任务。现在,当你能够说,例如,以下内容时,难道不是很棒吗?

task "PayPal Integration" takes 2 weeks

事实上,你可以。这种流畅性是通过明智地定义时间段常量来实现的,使其值具有 *意义*。例如。

let hours = 1
let hour = 1
let days = 2
let day = 2
let weeks = 3
let week = 3
let months = 4
let month = 4

只要它们是不同的,值就不重要。现在我们可以定义一个任务……

type Task() =
  [<DefaultValue>] val mutable Name : string
  [<DefaultValue>] val mutable Duration : string

……并将其添加到项目中。

let task name takestoken count timeunit =
  let t = new Task()
  t.Name <- name
  let dummy = 1 + count
  match timeunit with
  | 1 -> t.Duration <- String.Format("{0}h", count)
  | 2 -> t.Duration <- String.Format("{0}d", count)
  | 3 -> t.Duration <- String.Format("{0}wk", count)
  | 4 -> t.Duration <- String.Format("{0}mon", count)
  | _ -> raise(ArgumentException("only spans of hour(s), day(s), week(s) and month(s) are supported"))
  let g = List.hd my_project.Groups
  g.Tasks <- t :: g.Tasks

请注意,对于每个时间段,我稍微改变了持续时间的表述方式,以便 Project 能够接受该规范。上面的占位符表达式告诉 F# `count` 是一个整数——当然,我也可以显式定义它,但我太懒了。哦,顺便说一句,请注意我们很容易找到 *当前*(即最后一个)任务。因为我们使用的是 F#,所以这个任务实际上在列表的 *第一个* 位置,所以我们可以直接调用 `List.hd`。

生成项目

我们已经有了 *一切*,并准备生成项目。以下(有点俗气)的命令可以做到这一点。

prepare my_project

现在,我将向你展示 *整个* `prepare` 定义,它使用 Project API 来创建,嗯,项目。请注意 F# 有多简洁。

let prepare (proj:Project) =
  let app = new ApplicationClass()
  app.Visible <- true
  let p = app.Projects.Add()
  p.Name <- proj.Name
  proj.Resources |> List.iter(fun r ->
    let r' = p.Resources.Add()
    r'.Name <- r.Position // position, not name :)
    let tables = r'.CostRateTables
    let table = tables.[1]
    table.PayRates.[1].StandardRate <- r.Rate
    table.PayRates.[1].OvertimeRate <- (r.Rate + (r.Rate >>> 1)))
  // make root task with project name
  let root = p.Tasks.Add()
  root.Name <- proj.Name
  // add groups
  proj.Groups |> List.rev |> List.iter(fun g -> 
    let t = p.Tasks.Add()
    t.Name <- g.Name
    t.OutlineLevel <- 2s
    // who is responsible for this group?
    t.ResourceNames <- g.Person.Position
    // add tasks
    let tasksInOrder = g.Tasks |> List.rev
    tasksInOrder |> List.iter(fun t' ->
        let t'' = p.Tasks.Add(t'.Name)
        t''.Duration <- t'.Duration
        t''.OutlineLevel <- 3s
        // make task follow previous
        let idx = tasksInOrder |> List.findIndex(fun f -> f.Equals(t'))
        if (idx > 0) then 
          t''.Predecessors <- Convert.ToString(t''.Index - 1)
      )
    )

是的,我们终于使用 `List.rev` 来反转那些反向列表了——这可能不是世界上最快的操作,但我不在乎。所有重要的就是脚本能够运行并给我们想要的结果——资源定义、组名和分组链接的任务。项目经理还能要求什么?(实际上还能要求不少,但那是另一回事。)

因此,一个完整的项目定义可以如下所示。

project "F# DSL Article" starts "01/01/2009"
resource "Dmitri" isa "Writer" with_rate 140
resource "Computer" isa "Dumb Machine" with_rate 0
group "DSL Popularization" done_by "Dmitri"
task "Create basic estimation DSL" takes 1 day
task "Write article" takes 1 day
task "Post article and wait for comments" takes 1 week
group "Infrastructure Support" done_by "Computer"
task "Provide VS2010 and MS Project" takes 1 day
task "Download and deploy TypograFix" takes 1 day
task "Sit idly while owner waits for comments" takes 1 week
prepare my_project

结论

本文表明,使用 F# 构建 DSL 非常简单。当然,DSL 的本质是它们是 *领域特定* 的,所以对于你选择的领域,你可能会遇到更多的挑战。祝你玩得开心!

历史

  • 2009 年 8 月 16 日:首次发布
© . All rights reserved.