# 八、创建应用

在这一章中，我想展示用 F# 编写一个应用涉及到什么。对于我们将要实现的东西来说，“应用”可能是一个过于宏大的词，但重点是展示 F# 与其他技术和框架的交互方式，就像你要创建一个真实世界的应用一样。

我们将在 HTML 网页中创建一个自动完成下拉列表。想法是输入你感兴趣的村庄、城镇或城市，自动完成功能将帮助你在系统中找到正确的。我们将使用 RavenDB 作为我们的数据存储，使用 PicoMvc 作为 Mvc 网络框架来处理应用层。PicoMvc 是我专门为 F# 创建的一个 Mvc 框架。它很大程度上是受 OpenRasta、FubuMvc 和一点点 ASP.NET MVC 的启发，所以如果您不喜欢使用 PicoMvc，这里给出的例子可能很容易移植到这些框架中的任何一个。RavenDB 是一个用 C#实现的 NoSQL 数据库。我喜欢这个数据库，不是因为它速度快，并且通过分片为可伸缩性提供了很好的支持，这是毫无疑问的，而是因为它的实现者真正关注的是让开发人员非常容易使用它。

如今，在 HTML 表单中创建自动完成下拉列表已经相当普遍了，有一个很好的 jQuery 插件可以处理 UI 方面的事情。将数据加载到 RavenDB 中，然后公开一个服务，该服务将返回与用户搜索相对应的 JSON 记录，这将是本章的重点。

这个例子的完整代码可以在[https://github . com/robertpi/PicoMvc/tree/master/examples/AutoComplete](https://github.com/robertpi/PicoMvc/tree/master/examples/AutoComplete)上的 PiComvc 本身的例子目录中找到。

## 项目设置

我们的 Visual Studio 解决方案将有四个项目: **Common.fsproj** 、**load communities . fsproj**(community 是我们对村庄/城镇/城市的通称)、 **Web.fsproj** 和 **WebHost.csproj** 。

![](img/image009.png)

图 8:自动完成项目设置

**Common** 项目包含我们的类型的定义，这些类型将存储在 RavenDB 中。通用项目将参考**负载社区**和**网络**项目。**加载社区**项目将包含将数据加载到 RavenDB 的 ETL 逻辑。**网页**项目将包含驱动网页的逻辑。 **WebHost** 项目是一个 c#“web 项目”——它只是为了保存项目的 HTML 部分，并使启动 web 服务器进行调试变得更容易。

现在项目已经建立到我们满意的程度，是时候看看应用的 ETL(提取、转换和加载)部分的一些实际代码了。

## 提取/转换/加载

由于我住在法国，我们将使用基于法国村庄、城镇和城市的数据，但这里描述的技术将很容易适用于您居住的任何地方。首先，我们需要下载数据。我从[http://www.galichon.com/codesgeo/](http://www.galichon.com/codesgeo/)获得数据。您可以点击**法国乡村地理坐标**下的 **Télécharger la base** 链接，或者直接从[http://www.galichon.com/codesgeo/data/ville.zip](http://www.galichon.com/codesgeo/data/ville.zip)下载文件。它不是世界上最好的数据源，但它是我找到的最好的免费数据源。一旦你解压了 Excel 文件并把它转换成了 CSV 文件，把它加载到 RavenDB 中就非常简单了。

首先我们需要设计一个类型来保存**公共**项目中的数据:

```fs
    type Commune = 
        { mutable Id: string 
          Name: string 
          Postcode: string } 

```

我们只存储公社的名称及其邮政编码，因为这是我们要搜索或显示的全部内容——因此字段`Name`和`Postcode`。当涉及到添加或删除字段时，RavenDB 相当健壮，所以从最少的一组数据开始并在以后添加东西是很好的。`Id`字段是记录的唯一标识符。它是可变的，因为这似乎更适合 RavenDB。我们可以让 RavenDB 为我们生成这个，但是因为 INSEE，法国政府的统计和经济研究局，给每个村庄分配了它自己的唯一标识符，它包含在文件中，我们将使用这个。在法国，几个公社可以共享同一个邮政编码，所以这不是一个很好的标识符候选。

一旦我们设计了存储公社数据的类型，从文件中加载数据并将其存储在 RavenDB 中的代码就相当简单了:

```fs
    let loadCommuneData() = 
        use store = DocumentStore.OpenInitializedStore() 
        let lines = File.ReadLines(Path.Combine(__SOURCE_DIRECTORY__, @"ville.csv"), System.Text.Encoding.Default) 

        use session = store.OpenSession() 
        session.Advanced.MaxNumberOfRequestsPerSession <- 30000 
        lines 
        |> Seq.skip 1 
        |> Seq.iteri(fun i line -> 
            let line = line.Split(';') 
            match line with 
            | [|  name; nameCaps; postcode; inseeCode; region; latitude; longitude; eloignementf|] -> 
                let id = sprintf "communes/%s" (inseeCode.Trim()) 
                printfn "Doing %i %s (%s)" i name id 
                let place: Commune = 
                    { Id = id 
                      Name = name.Trim() 
                      Postcode = postcode.Trim() } 
                session.Store(place) 
                if i % 1000 = 0 then session.SaveChanges() 
            | line -> printfn "Error in line: %A" line) 
        session.SaveChanges()

```

有几点值得强调:

*   我们使用`File.ReadLines`给我们一个文件中所有行的整数。这给了我们一个很好的方便的方法来一行行地读取文件，而不用把它全部加载到内存中。
*   请注意，我们正在通过`System.Text.Encoding.Default`到`File.ReadLines`。法国公社的名字中经常带有重音字符，所以我们需要确保使用正确的编码。
*   有必要设置`session.Advanced.MaxNumberOfRequestsPerSession`，因为默认情况下它被限制为 10，这意味着在 10 次请求或存储后，会话将抛出异常。通常会话是短暂的，所以这个异常是对开发人员的早期警告。因为这是会话的一种非典型用法，所以可以设置这个数字。但是，我认为会话会缓存它们存储的数据，因此您可能希望在每次写入 RavenDB 后清除会话。在这种情况下，似乎没有太大的区别。
*   我们使用`Seq.iteri`枚举文件中的每一行。这给了我们行加上行号。我们可以使用行号通过调用`.SaveChanges()`来执行每 1000 个项目的保存。这似乎比每行后保存或试图一次性保存整个批次更有效。我没有对这个数字做过多少实验；可能有一个比 1000 更理想的数字。
*   文件的解析非常简单，我们只需在每一行上调用`.Split(';')`，然后在结果数组上进行模式匹配来解包相关的项目。然后，使用会话的`Store()`方法，将它们加载到`Commune`类型中并存储在 RavenDB 中。如前一条所述，在调用`.SaveChanges()`之前，这些不会被刷新到数据库。

这就结束了。数据在数据库中，您可以使用 RavenDB 的管理控制台验证这一点，如下图所示。

![](img/image010.jpg)

图 9:数据库管理控制台

## 支持网站的代码

现在我们在 RavenDB 中有了数据，我们希望能够向用户显示数据。为此，我们需要创建一个服务，将 JSON 文档返回给客户端。用 PicoMvc 和 RavenDB 实现服务很简单，但是要实现它，我们需要配置 PicoMvc 并在 RavenDB 中创建一个索引，以便我们可以查询它。

PicoMvc 旨在将 F# 函数映射到 URL 和 HTTP 动词。这个想法是，基本框架独立于 web 服务器和主机平台，但是我们提供了钩子，允许您将 PicoMvc 插入现有平台。目前，唯一存在的钩子是将 PicoMvc 插入 ASP.NET 平台，这是通过一个名为`PicoMvcRouteHandler`的类来完成的，它是一个 ASP.NET 路由处理器。这个想法是，您向 ASP.NET 运行时注册这个路由处理程序，它提供了所有的管道，用于将 ASP.NET HTTP 处理程序将接收的调用和请求映射到您通过 PicoMvc 定义的处理程序函数。

因为`PicoMvcRouteHandler`只是一个普通的 HTTP 处理程序。它要求您在`global.asax`中向 ASP.NET 运行时注册它:

```fs
            routes.Add(new Route("{*url}", new PicoMvcRouteHandler("url", routingTables, actions)))

```

`PicoMvcRouteHandler`也需要一点配置。路由处理程序的第一个参数是一个字符串，它告诉它添加路由处理程序时匹配的 URL 的名称。然后，它将使用这个作为 URL 来解析将调用哪个函数。下一个参数是路由表，它保存着应该为哪些 URL 调用哪些函数的信息。您可以通过调用静态方法`LoadFromCurrentAssemblies`，使 PicoMvc 自动在所有加载的程序集中搜索标记有`[<Controller>]`属性的 F# 模块。

```fs
            let routingTables = RoutingTable.LoadFromCurrentAssemblies()

```

路由处理器的第三个也是最后一个参数告诉路由处理器应该如何处理动态调用的函数的参数和返回结果。PicoMvc 中有许多预定义的动作。例如，有一个操作将根据参数的名称在查询字符串或发布变量中查找一个值。这叫`ParameterActions.defaultParameterAction`。定义新动作也相当容易。例如，如果我们看到一种类型的`IDocumentStore`，我们将需要一个返回对 RavenDB 文档存储的引用的操作。这就是我们的制作方法:

```fs
            let ravenParameterAction =
                { CanTreatParameter = fun _ _ t -> t = typeof<IDocumentStore>
                  ParameterAction = fun _ _ _ -> !store :> obj }

```

还需要为我们的数据定义一个索引，RavenDB 将在它的查询中使用这个索引。因为它使用 C#“一元”语法，所以它与 C#语言紧密相关。我发现用 C#实现这一点更容易:

```fs
        public class Communes_Search : AbstractIndexCreationTask
        {
            public override IndexDefinition CreateIndexDefinition()
            {
                return new IndexDefinitionBuilder<Commune>
                {
                    Map = communes => from commune in communes select new { commune.Name, commune.Postcode }
                }.ToIndexDefinition(this.Conventions);
            }
        }

```

从我们的`global.asax`中加载这个就很简单了:

```fs
            let assem = Assembly.Load("WebHost")
            IndexCreation.CreateIndexes(assem, !store)

```

既然我们已经解决了配置 PicoMvc 和 RavenDB 的问题，我们就准备好攻击实现服务本身了。

## JSON 服务

为了创建自动完成下拉列表，我们需要查询 RavenDB，然后将结果作为 JSON 文档发送到用户的网页。实现 JSON 服务非常简单:

```fs
    type AutoCompleteResult =
        { id: string;
          label: string;
          value: string }

    [<Controller>]
    module Commune =
        let get (term: string) (store: IDocumentStore) =
            use session = store.OpenSession()
            let postcodeRegex = new Regex(@"^\d+$")

            let comQuery = session.Advanced.LuceneQuery<Commune>("Communes/Search")
            let comQuery =
                if postcodeRegex.IsMatch term then
                    comQuery.WhereStartsWith("Postcode", term)
                else
                    comQuery.WhereStartsWith("Name", term)
            let query = comQuery.Take(20)
            let res = query |> Seq.map (fun x -> { id = x.Id; label = sprintf "%s (%s)" x.Name x.Postcode; value = sprintf "%s (%s)" x.Name x.Postcode})
            Result res

```

首先我们定义一个类型，`AutoCompleteResult`，来保存我们想要发送回客户端的结果。这将被直接翻译成 JSON。

接下来我们定义一个 PicoMvc 控制器。这只是一个标有`[<Controller>]`属性的 F# 模块。因为模块的名字是`Commune`，所以它会暴露在 **~/commune.xxx** 的网址上，其中 xxx 用于确定哪个视图将用于渲染结果(我们如何选择视图来处理结果将在后面讨论)。在这种情况下，我们的网址将是 **~/commune.json** ，视图将把结果呈现为 json。

我们的控制器定义了处理它可能接收到的不同 HTTP 动词的函数。在这种情况下，我们只想处理 GET 动词请求，所以我们定义了一个`get`函数。该函数的参数项将由查询字符串中的项目`term`填充，`store`将由对 RavenDB 文档存储的引用填充。

一旦我们有了`term`参数和`store`参数来为我们的 RavenDB 存储提供参考，实现服务就很简单了。我们使用高级 Lucene 查询来查询`Communes/Search`索引。然后我们测试我们的输入术语是邮政编码还是公社。在法国，邮政编码完全是数字，所以我们可以使用一个简单的正则表达式来执行这个测试。一旦我们知道该术语是邮政编码还是城镇名称，我们就可以使用 Lucene 查询的`WhereStartsWith`方法来查询`Name`或`Postcode`字段。然后，我们将查询限制为 20 个结果，并将其转换为客户端期望的格式。

为此创建一个用户界面很容易。我的用户界面基于以前在 jQuery 开发包中可用的**remote.html**示例(该示例包含在本书的可下载代码示例中)。这只是一个改变网址和一些标签的问题。

有几个可以改进的地方:

*   很多公社都是以“圣人”这个词开始的用户可能合理地期望缩写“St”映射到“Saint”
*   公社名称中的单词用破折号隔开。允许用户使用空间可能是个好主意。
*   我不确定 RavenDB 对口音的处理有多好。将“e”同时映射到“é”和“è”会很好。如果 RavenDB 支持这个功能，那么自己编写代码相当容易；您只需要存储名称的无重音版本，并基于此进行搜索。

## 总结

就这样。这需要一点解释，但是我们在这个过程中学习了很多关于 PicoMvc 的知识，最终的解决方案根本没有太多代码。事实上，整个解决方案大约有 150 行 F# 和 C#。你可以在[的【github 上的 PicoMvc 的**示例**目录中看到最终的解决方案。](https://github.com/robertpi/PicoMvc/tree/master/examples/AutoComplete)