2019 年 9 月

第 34 卷,第 9 期

[F#]

使用 .NET Core 上的 F# 完成一切

作者:Phillip Carter

F# 是 .NET 的函数式编程语言 (bit.ly/2y4PeQG)。它是跨平台语言,并与所有 .NET 语言一样,为开源语言 (github.com/dotnet/fsharp)。在 Microsoft,我们非常喜欢使用 F#,因为它为 .NET 引入了函数式编程。对于不熟悉此概念的人来说,函数式编程范例主要有几个特点:

  • 将函数作为对数据进行操作的主要构造
  • 使用表达式而非语句
  • 更多使用不可变的值,而非变量
  • 声明式编程多过命令式编程

这意味着 F# 为 .NET 引入了一些出色功能:

  • 一流的函数(可以作为值传递给其他函数并从其他函数返回)
  • 轻型语法,侧重使用表达式和值,而非语句
  • 内置的不可变性和非 null 类型
  • 丰富的数据类型和高级的模式匹配技术

典型的 F# 代码通常看起来与图 1 类似。

图 1:典型 F# 代码

// Group data with Records
type SuccessfulWithdrawal = {
  Amount: decimal
  Balance: decimal
}
type FailedWithdrawal = {
  Amount: decimal
  Balance: decimal
  IsOverdraft: bool
}
// Use discriminated unions to represent data of 1 or more forms
type WithdrawalResult =
  | Success of SuccessfulWithdrawal
  | InsufficientFunds of FailedWithdrawal
  | CardExpired of System.DateTime
  | UndisclosedFailure
let handleWithdrawal amount =
  // Define an inner function to hide it from callers
  // Returns a WithdrawalResult
  let withdrawMoney amount =
    callDatabaseWith amount // Let's assume this has been implemented :)
  let withdrawalResult = withdrawMoney amount
  // The F# compiler enforces accounting for each case!
  match w with
  | Success s -> printfn "Successfully withdrew %f" s.Amount
  | InsufficientFunds f -> printfn "Failed: balance is %f" f.Balance
  | CardExpired d -> printfn "Failed: card expired on %O" d
  | UndisclosedFailure -> printfn "Failed: unknown :("

除了这些核心功能之外,F# 还可以与整个 .NET 进行互操作,且完全支持对象、界面等。更高级的 F# 编程技术通常涉及巧妙将面向对象 (OO) 的功能与函数代码相结合,但不损害函数编程范例。

此外,F# 还具有许多人们喜爱的独特功能,如计算表达式、度量单位,以及记录和可辨识联合等功能强大的类型。有关 F# 语言参考的更多详细信息,请参阅 bit.ly/2JSnipy。F# 还有助于形成一种侧重安全性和正确性的编程风格:许多开发人员在已拥有其他语言的丰富经验后转而使用 F#,这些语言不像 F# 这样侧重安全性和正确性。它影响了许多最近引入了 C# 的内容,如异步、元组、模式匹配和即将引入的可为 null 的引用类型功能集。

F# 还有一个充满活力的社区,热衷于突破 .NET 的界限,创造令人难以置信的开源组件。在 .NET 以及开创性 UI 库、数据处理库、测试方法、Web 服务等各个 .NET 领域,该社区极富创新性和令人难以置信的价值!

丰富的功能和由热情的开发人员组建的充满活力的社区已吸引许多人开始使用 F# 来完成工作和获取乐趣。F# 正在为世界各地的金融机构、电子商务系统、科学计算、从事数据科学和机器学习的组织、咨询公司等提供助力和支持。Microsoft 人员也经常使用 F#:作为 Microsoft Research 使用的主要语言之一,它影响了 Q# 开发平台、Azure 和 Office 365 的部分功能,甚至 F# 编译器和适用于 F# 的 Visual Studio 工具的发展并为其提供强大支持!简而言之,它无处不在。

感兴趣吗?感觉很棒!在本文中,我将介绍如何使用 .NET Core 上的 F# 执行一些很酷的操作。

概述

我会先介绍有关 .NET Core 上 F# 的基础知识,然后进一步介绍更高级(和更有趣)的功能,包括如何使用 .NET CLI 在任何操作系统上创建控制台应用程序和库项目。此工具集虽然内容精简,但与 Visual Studio Code 等编辑器和官方 F# 插件 Ionide (ionide.io) 结合使用时,便足以开发完整的应用程序。还可以使用 Vim 或 Emacs,如果它们是你喜欢和熟悉的工具的话。

接下来,我将简要概述一些用于构建 Web 服务的技术。其中每一种技术都有一些值得一提的优势。然后我会展示其中一种技术的示例。

最后,我会探讨如何利用 .NET Core 中的一些高性能构造,如 Span<'T>,来减少分配量和加快系统中的热路径。

通过 .NET CLI 开始使用 F#

最简单的 F# 入门方法便是使用 .NET CLI。首先,确保已安装最新的 .NET SDK。

现在来创建一些彼此之间相互关联的项目。由于实现了现代终端支持选项卡,即使仍需要输入一些内容,你的环境应该能够完成大部分工作。首先,创建一个新的解决方案:

dotnet new sln -o FSNetCore && cd FSNetCore

创建一个名为 FSNetCore 的新目录,在该目录中创建一个解决方案文件,然后将目录更改为 FSNetCore。

接下来,创建一个库项目,并将其与解决方案文件关联:

dotnet new lib -lang F# -o src/Library
dotnet sln add src/Library/Library.fsproj

向该库添加一个包:

dotnet add src/Library/Library.fsproj package Newtonsoft.Json

将 Json.NET 包添加到项目中。

现在,将库项目中的 Library.fs 文件更改为以下内容:

module Library
open Newtonsoft.Json
let getJsonNetJson value =
  sprintf "I used to be %s but now I'm %s thanks to JSON.NET!"
   value (JsonConvert.SerializeObject(value))

这是一个包含单个 F# 函数的模块,使用 JSON.NET 将泛型值串行化为 JSON 字符串,并在其中包含消息的其余部分。

接下来,创建一个使用库的控制台应用:

dotnet new console -lang F# -o src/App
dotnet add src/App/App.fsproj reference src/Library/Library.fsproj
dotnet sln add src/App/App.fsproj

用以下内容替换控制台应用项目中的 Program.fs 内容:

open System
[<EntryPoint>]
let main argv =
  printfn "Nice command-line arguments! Here's what JSON.NET has to say about them:"
  argv
  |> Array.map Library.getJsonNetJson
  |> Array.iter (printfn "%s")
  0 // Return an integer exit code

此过程采用每个命令行参数,将其转换为由库函数定义的字符串,然后循环访问这些字符串并打印出来。

现在可运行项目:

dotnet run -p src/App Hello World

此过程会将以下内容打印到控制台:

Nice command-line arguments! Here's what JSON.NET has to say about them:
I used to be Hello but now I'm ""Hello"" thanks to JSON.NET!
I used to be World but now I'm ""World"" thanks to JSON.NET!

是不是很简单?现在添加一个单元测试并将其连接到解决方案和库:

dotnet new xunit -lang F# -o tests/LibTests
dotnet add tests/LibTests/LibTests.fsproj reference src/Library/Library.fsproj
dotnet sln add tests/LibTests/LibTests.fsproj

现在用以下内容替换控制台应用项目中的 Tests.fs 内容:

module Tests
open Xunit
[<Fact>]
let ``Test Hello`` () =
  let expected = """I used to be Hello but now I'm "Hello" thanks to JSON.NET!"""
  let actual = Library.getJsonNetJson "Hello"
  Assert.Equal(expected, actual)

这一简单测试仅验证输出是否正确。请注意,用双反撇号将测试名称括起来,这样允许为测试使用更自然的名称。这在 F# 测试中很常见。此外,在 F# 中嵌入带有引号的字符串时,使用三重引号字符串。或如果需要,也可以使用反斜线。

现在可运行测试:

dotnet test

从输出可以看出,已通过测试!

Starting test execution, please wait...
Test Run Successful.
Total tests: 1
Passed: 1
Total time: 4.9443 Seconds

没错!只需最少的工具,就可以构建经过单元测试的库和运行该库代码的控制台应用。仅使用这些工具就足以构建完整的解决方案,尤其是在与 Visual Studio Code 等 F# 代码编辑器和 Ionide 插件配合使用的情况下。事实上,许多专业 F# 开发人员只使用这个工具集来完成日常工作!可以在 GitHub bit.ly/2Svquuc 上找到完整的解决方案。

这些内容很有趣,现在,来看一些比库项目或控制台应用更有趣的内容。

利用 F# 构建 Web 应用

F# 的应用远不止于库项目和控制台应用。F# 开发人员最常构建的解决方案是 Web 应用和服务。主要有三种选项可用于实现此目的,这里对每个选项做个简单介绍:

Giraffe (bit.ly/2Z4zPeP) 最适合充当“ASP.NET Core 的函数式编程绑定”。 它本质上是一个中间件库,将路由公开为 F# 函数。Giraffe 有点像一个基础库,对 Web 服务或 Web 应用的构建方式相对而言基本没有要求。它提供了一些使用函数技术来组合路由的巧妙方法,并且提供一些不错的内置函数来简化运用 JSON 或 XML 之类的操作,但如何编写项目完全取决于你自己。很多 F# 开发人员使用 Giraffe,因为它很灵活,还因为它使用 ASP.NET Core 而具有一个坚实的基础。

Saturn (bit.ly/2YjgGsl) 有点像“包含电池的 ASP.NET Core 的函数式编程绑定”。 它使用 MVC 模式的一种形式,但是以函数形式而不是使用面向对象的编程抽象来实现功能。它建立在 Giraffe 的基础之上,并使用其核心抽象,但关于如何构建 Web 应用程序与 .NET Core,它提供了一种更具倾向性和引导性的方式。它还包含一些 .NET CLI 命令行工具,这些工具可用于生成数据库模型、迁移数据库以及构建 Web 控制器和视图的基架。由于更具引导性,Saturn 比 Giraffe 包含更多内置功能,但也需要为此支付费用。

Suave (bit.ly/2YmDRSJ) 比 Giraffe 和 Saturn 上市的时间早很多。它对 Giraffe 的构建产生过主要影响,因为它开创了编程模型,可在 F# 中用于 Web 服务的构建。一个关键区别在于 Suave 使用自己的兼容 OWIN 的 Web 服务器,而不是依托 ASP.NET Core。此 Web 服务器高度可移植,因为它可以通过 Mono 嵌入低功耗设备中。Suave 的编程模型比 Giraffe 的稍微简单一些,因为不需要使用 ASP.NET Core 抽象。

先使用 Giraffe 构建一个简单的 Web 服务。可通过 .NET CLI 轻松完成此操作:

dotnet new -i "giraffe-template::*"
dotnet new giraffe -lang F# -V none -o GiraffeApp
cd GiraffeApp

现在,可以通过运行 build.bat 或 sh build.sh 来生成应用程序。

现在运行项目:

dotnet run -p src/GiraffeApp

然后,可以通过 localhost 导航到模板 /api/hello 给出的路由。

导航到 https://localhost:5001/api/hello 时会得到:

{"text":"Hello world, from Giraffe!"}

您回答“很棒!”来看看这是如何生成的。为此,打开 Program.fs 文件并记下 WebApp 函数:

let webApp =
  choose [
    subRoute "/api"
      (choose [
        GET >=> choose [
          route "/hello" >=> handleGetHello
        ]
      ])
    setStatusCode 404 >=> text "Not Found" ]

这里有一些内容要运行,它实际上基于一些相当复杂的函数式编程概念。但它终究是一种特定于域的语言 (DSL),使用它时无需清楚了解它的运作原理。以下是基础知识:

WebApp 函数由名为 choose 的函数构成。每当有人向服务器发出请求时,ASP.NET Core 运行时便会执行 choose 函数。它会查看请求并尝试查找匹配的路由。如果找不到,会回退到底部定义的 404 路由。

因为我定义了下级工艺路线,因此 choose 函数会知晓要抓取其所有子路由。要抓取的内容的定义由一个内部 choose 函数及其路由列表指定。可以看到,在路由列表中,有一个路由指定“/hello”字符串作为其路由模式。

此路由实际上是另一个 F# 函数,称为 route。它采用一个字符串参数来指定路由模式,然后与 >=> 运算符结合。这相当于是说“此路由对应于依循它的函数”。 这种结构称为 Kleisli 结构,虽然使用运算符不一定要理解背后的原理,但应该知道它有一个坚实的数学基础。正如本文前面提到的,F# 开发人员倾向于正确性...还有什么比数学基础更正确的?

你会注意到,>=> 运算符右侧的函数 handleGetHello 并不在此处定义。现在打开定义它的文件 httpHandlers.fs:

let handleGetHello =
  fun (next: HttpFunc) (ctx: HttpContext) ->
    task {
      let response = {
        Text = "Hello world, from Giraffe!"
       }
      return! json response next ctx
    }

与“正常的”F# 函数不同,此处理程序函数实际上定义为 lambda:这是一流的函数。尽管此样式在 F# 中并不常见,但之所以选择此样式,是因为 lambda 采用的两个参数 next 和 ctx 通常由基础 ASP.NET Core 运行时来构造并传递给处理程序,而不一定是由用户代码来执行这些操作。从程序员的角度来看,无需自己传递这些参数。

Lambda 函数中定义的这些参数是在 ASP.NET Core 运行时中定义并由其自身使用的抽象。在 lambda 函数的主体中,使用这些参数,可以构造要串行化并沿 ASP.NET Core 管道发送的任何对象。称为 response 的值是包含单个标签 Text 的 F# 记录类型的实例。因为 Text 是字符串,会接收要串行化的字符串。此类型的定义位于 Model.fs 文件中。然后,该函数返回响应的 JSON 编码的表示形式,以及 next 和 ctx 参数。

另一种研究 Giraffe 管道的方法是,在函数的顶部和底部使用一些小样本,以符合 ASP.NET Core 管道抽象以及期间的任何内容:

let handlerName =
  fun (next: HttpFunc) (ctx: HttpContext) ->
    task {
      // Do anything you want here
      //
      // ... Well, anything within reason!
      //
      // Eventually, you’ll construct a response value of some kind,
      // and you’ll want to serialize it (as JSON, XML or whatever).
      //
      // Giraffe has multiple middleware-function utilities you can call.
      return! middleware-function response next ctx
    }

尽管这看起来需要事先学习很多东西,但使用这种方法构建 Web 应用和服务非常高效且简单。为了演示这一点,我将在 HttpHandlers.fs 文件中添加新的处理程序函数,它会在你指定自己的名称时显示问候语:

let handleGetHelloWithName (name: string) =
  fun (next: HttpFunc) (ctx: HttpContext) ->
    task {
      let response = {
        Text = sprintf "Hello, %s" name
       }
      return! json response next ctx
    }

和之前一样,这里使用必要的样板设置此处理程序,以符合 ASP.NET Core 中间件。一个关键区别是我的处理程序函数采用字符串作为输入。我使用与以前相同的响应类型。

接下来,在 Program.fs 文件中添加新路由,但由于我想指定一些任意字符串作为输入,因此需要使用路由函数以外的内容。Giraffe 专为此目的定义 routef 函数:

let webApp =
  choose [
    subRoute "/api"
      (choose [
        GET >=> choose [
          route "/hello" >=> handleGetHello
          // New route function added here
          routef "/hello/%s" handleGetHelloWithName
        ]
      ])
    setStatusCode 404 >=> text "Not Found" ]

Routef 函数接受两个输入:

  • 表示路由及其输入的格式字符串(本例中为带 %s 的字符串)
  • 处理程序函数(之前定义的函数)

可以看到,这里没有提供 >=> 运算符。这是因为 routef 有两个参数:字符串模式(由 F# 格式字符串指定)和处理程序,用于处理由 F# 格式字符串指定的类型。这与路由函数不同,路由函数仅采用字符串模式作为输入。在此例中,由于不需要将 routef 和处理程序与任何其他内容结合,因此不使用 >=> 运算符来编写其他处理程序。但如果我之前想执行一些操作,如设置特定的 HTTP 状态代码,我会通过结合 >=> 来执行此操作。

现在,可以重新生成应用并导航到 https://localhost:5001/api/hello/phillip。操作完后会得到:

{"text":"Hello, Phillip”}

没错!是不是很简单?与任何库和框架一样,需要了解一些知识,但一旦熟悉了抽象,添加用于执行所需操作的路由和处理程序就会变得异常简单。

可以通过阅读 Giraffe 的相关文档进一步了解其运作原理 (bit.ly/2GqBVhT)。还可以在 bit.ly/2Z21yNq 找到一个可运行的示例应用,它展示了我之前演示的内容。

速度更快

介绍完 F# 在实际中的应用,现在来深入了解一些性能特征。

在构建高流量的 Web 服务等内容时,性能非常重要!具体来说,避免让 GC 为不必要的分配量执行清理操作,往往是所能执行的对长时运行的 Web 服务器进程产生最大影响的操作之一。

在使用 F# 和 .NET Core 时,这项操作是 Span<'T> 这样的类型发挥巨大效用的时刻。“跨度”类似于进入数据缓冲区的时段,可用于读取和操作该数据。Span<'T> 在其使用方式上存在多种限制,以便运行时可以保证实现各种性能增强。

我将用一个示例进行演示(如 Steven Toub 的“关于跨度的一切:探索新的 .NET Mainstay”中所示,网址:msdn.com/magazine/mt814808)。我将使用 BenchmarkDotNet 来衡量结果。

首先,在 .NET Core 上创建一个控制台应用:

dotnet new console -lang F# -o Benchmark && cd Benchmark
dotnet add package benchmarkdotnet

接下来,对其进行修改,用于对具有典型实现的例程和使用 Span<'T> 的实现进行基准检验,如图 2 所示。

图 2:对具有或没有 Span<'T> 的分析例程进行基准检验

open System
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
module Parsing =
  /// "123,456" --> (123, 456)
  let getNums (str: string) (delim: char) =
    let idx = str.IndexOf(delim)
    let first = Int32.Parse(str.Substring(0, idx))
    let second = Int32.Parse(str.Substring(idx + 1))
    first, second
  let getNumsFaster (str: string) (delim: char) =
    let sp = str.AsSpan()
    let idx = sp.IndexOf(delim)
    let first = Int32.Parse(sp.Slice(0, idx))
    let second = Int32.Parse(sp.Slice(idx + 1))
    struct(first, second)
[<MemoryDiagnoser>]
type ParsingBench() =
  let str = "123,456"
  let delim = ','
  [<Benchmark(Baseline=true)>]
  member __.GetNums() =
    Parsing.getNums str delim |> ignore
  [<Benchmark>]
  member __.GetNumsFaster() =
    Parsing.getNumsSpan str delim |> ignore
[<EntryPoint>]
let main _ =
  let summary = BenchmarkRunner.Run<ParsingBench>()
  printfn "%A" summary
  0 // Return an integer exit code

名为 Parsing 的模块包含两个函数,这些函数使用给定的分隔符拆分某个字符串,并分别返回表示半个字符串的元组。但是,一个名为 getNumsFaster 的模板同时使用 Span<'T> 和结构元组来消除分配。你会看到,结果惊人。

我将运行基准检验以生成结果:

dotnet run -c release

此过程会生成可以作为标记、HTML 或其他格式来共享的结果。

我在笔记本电脑上运行了此基准检验,电脑的硬件和运行环境为:

  • BenchmarkDotNet v0.11.5
  • macOS Mojave 10.14.5 (18F132) [Darwin 18.6.0]
  • Intel Core i7-7700HQ CPU 2.80GHz (Kaby Lake)、1 个 CPU、8 个逻辑核心和 4 个物理核心
  • .NET Core SDK=3.0.100-preview5-011568 (64-bit)

结果如图 3 所示。

图 3:基准检验结果

Method 平均值 错误 标准偏差 比率 Gen0 Gen1 Gen2 已分配
GetNums 90.17 ns 0.6340 ns 0.5620 ns 1.00 0.5386 - - 88 B
GetNumsFaster 60.01 ns 0.2480 ns 0.2071 ns 0.67 - - - -

印象深刻,对吧?getNumsFaster 例程不仅分配了 0 个额外字节,而且运行速度还提高了 33%!

如果你仍质疑它的重要性,那么想象这么一个应用场景:需要对数据执行 100 次转换,而对于大流量的 Web 服务,这一切都必须发生在热路径上。如果该服务的请求量为每秒百万量级,则如果在每个转换(或几个转换)中均进行分配,就会产生相当严重的性能问题。但如果使用 Span<'T> 和结构元组这样的类型,通常不会产生上述分配。而且,正如基准检验所示,执行给定操作的时钟时间也会大大减少。

总结

从文中可以看出,在 .NET Core 上,可以使用 F# 执行大量操作!它很容易入门,并能轻松根据自己的需要构建 Web 应用程序。此外,由于能够使用 Span<'T> 这样的构造,F# 也可用于完成性能敏感型任务。

F# 在 .NET Core 上不断得到优化,社区也在持续发展。我们十分希望你加入到社区中来,在未来不断为 F# 和 .NET 构建很棒的内容和功能!


Phillip Carter 是 Microsoft .NET 团队的一员。他工作时主要使用 F# 语言和工具、F# 文档、C# 编译器和适用于 Visual Studio 的 .NET 项目集成工具。

衷心感谢以下技术专家对本文的审阅:Dustin Moris Gorski


在 MSDN 杂志论坛讨论这篇文章