本节将讨论如何在一个应用中一起使用 C#和 F#代码。我们将创建一个简单的应用,它的前端用户界面用 C#实现,后端数据管理用 F#实现。无论是创建 Windows 窗体还是与 ASP.NET 一起创建 web 应用,混合命令式和函数式编程风格可能是最有效的方法——当然,用于设计窗体或网页的工具,XAML 或 HTML,都对生成 C#或 VB 代码有更多的支持。此外,因为用户界面一直在管理状态,应用中本质上可变的部分最好用支持可变的语言来实现。相反,处理数据持久化和转换的应用后端通常非常适合 F#,因为不变性对于处理异步流程至关重要。
第一步很简单:在 Visual Studio 2012 中创建新的 Windows 窗体应用项目。我已经调用了我的 fsharp-demo :
图 1:新的 Windows 窗体项目
接下来,右键单击解决方案并选择添加 > 新项目。在左侧树中,点击其他语言展开选择列表:
图 2:可用的编程语言
点击可视化 F# 并选择 F#库。输入名称,例如 fsharp-demo-lib 。请注意 Visual Studio 如何创建存根**。带有默认类的文件:**
图 3:新的 F#库
不幸的是,这个例子中生成的代码并不是我们真正想要的——Visual Studio 在 F#中为我们创建了一个命令式的类模板,而我们想要的是一个函数式编程模板。因此,首先用模块名替换生成的代码。我们这样做是因为let
语句是静态的,需要一个由编译器作为静态类实现的模块。相反,我们可以写,例如:
module FSharpLib
let Add x y = x + y
我们也去掉了存根类,创建了一个可以从 C#调用的简单函数。如果省略模块名,模块名将默认为文件名,在我们的例子中是 Library1 。
如果需要使用命名空间来避免命名冲突,可以这样做:
namespace fsharp_demo_lib
module FSharpLib =
let Add x y = x + y
请注意,现在明确需要=
运算符。这是因为当与命名空间结合时,模块现在在该命名空间内是本地的,而不是顶层的。【61】在 C#代码中,我们必须在解析函数调用时添加using
fsharp_demo_lib;
语句或者显式引用命名空间,例如fsharp_demo_lib.FSharpLib.Add(1, 2);
。在本节的剩余部分,我不会在 F#中使用名称空间。
接下来,您将希望在 C#项目中向 F#项目添加一个引用:
图 4:引用 C#项目
现在你会注意到一些有趣的事情——如果你转到 Form1.cs 并输入以下内容:
public Form1()
{
int result = FSharpLib.Add(1, 2); // <<== start typing this
InitializeComponent();
}
您会注意到,集成开发环境将此标记为未知类,智能感知不起作用。在 F#代码中进行更改时,您总是需要构建解决方案,然后这些更改才会被 C#端的 IDE 发现。
在构造函数中完成测试用例:
public Form1()
{
int result = FSharpLib.Add(1, 2);
MessageBox.Show("1 + 2 = " + result);
InitializeComponent();
}
当我们运行应用时,首先会出现一个带有结果的消息框。恭喜,我们已经成功地从 C#调用了 F#。
我们可能还想走另一个方向,从我们的 F#代码中调用 C#。我们已经看到了许多这样的例子,但是假设您想在自己的应用中调用一些 C#代码。与任何多项目解决方案一样,我们不能有循环引用,所以这意味着您必须了解解决方案的结构,这样 C#和 F#项目之间共享的公共代码必须进入自己的项目。
下一个问题是您是想要在静态还是实例上下文中调用 C#代码。静态上下文类似于调用 F#代码。首先,简单地创建一个静态类和一些静态成员:
namespace csharp_demo_lib
{
public static class StaticClass
{
public static void Print(int x, int y)
{
MessageBox.Show("Static call, x = " + x + " y = " + y);
}
}
}
在 F#代码中,我们添加了对 C#库的引用,并使用open
关键字(相当于 C#中的using
关键字)引用命名空间:
module FSharpLib
open csharp_demo_lib
let Add x y =
StaticClass.Print(x, y)
x + y
相反,我们可以实例化一个 C#类,并在可变的上下文中操作它的成员,以及调用方法。例如:
namespace csharp_demo_lib
{
public class InstanceClass
{
public void Print()
{
MessageBox.Show("Instance Call");
}
}
}
在 F#中:
module FSharpLib
open csharp_demo_lib
let Add x y =
let inst = new InstanceClass()
inst.Print()
StaticClass.Print(x, y)
x + y
现在我们已经了解了 C#和 F#交互的基础,让我们创建一个简单的“数据库浏览器”应用。这个应用将使用 Syncfusion 的 Essential Studio 作为 C#的前端,而 F#作为所有数据库连接和查询的后端。该用户界面将包括:
- 用户可以从中选择表格的列表控件。
- 将显示选定表格内容的网格控件。
在 F#中,后端将:
- 查询数据库模式以获取表列表。
- 在数据库中查询选定的表数据。
我们将连接到 AdventureWorks2008 数据库。
源代码可以在https://github.com/cliftonm/DatabaseExplorer从 GitHub 查看和克隆。
让我们从编写 F#后端开始,并结合一些简单的单元测试【62】(用 F#编写!)在测试驱动开发【64】过程中使用 xuit【63】,因为这也说明了如何为 F#编写单元测试。我们使用 xUnit 而不是 nUnit,因为在撰写本文时,nUnit 不支持。NET 4.5 组件,而xunit.gui.clr4.exe测试运行程序则有。
我们将从一个单元测试开始,该测试验证与我们的数据库建立了连接,如果我们给它一个错误的连接字符串,我们会得到一个SqlException
:
module UnitTests
open System.Data
open System.Data.SqlClient
open Xunit
open BackEnd
type BackEndUnitTests() =
[<Fact>]
member s.CreateConnection() =
let conn = openConnection "data source=localhost;initial catalog=AdventureWorks2008;integrated security=SSPI"
Assert.NotNull(conn);
conn.Close
[<Fact>]
member s.BadConnection() =
Assert.Throws<SqlException>(fun () ->
BackEnd.openConnection("data source=localhost;initial catalog=NoDatabase;integrated security=SSPI") |> ignore)
请注意,我们必须显式地处理返回“忽略”内容的函数的结果
支持的 F#代码:
module BackEnd
open System.Data
open System.Data.SqlClient
// Opens a SqlConnection instance given the full connection string.
let openConnection connectionString =
let connection = new SqlConnection()
connection.ConnectionString <- connectionString
connection.Open()
connection
接下来,我们将加载数据库模式。当接口 C#和 F#代码时,最好尽可能地留在 F#命名空间和构造中,编写单独的函数来从 F#构造转换成 C#通常使用的命令(可变)结构。首先,让我们编写一个简单的单元测试,确保我们得到一些结果:
[<Fact>]
member s.ReadSchema() =
use conn = s.CreateConnection()
let tables = BackEnd.getTables conn
Assert.True(tables.Length > 0)
// Verify that some known table exists in the list.
Assert.True(List.exists(fun (t) -> (t.tableName = "Person.Person")) tables)
注意use
关键字,【65】,当变量超出范围时会自动调用Dispose
。
支持的 F#代码如下。请注意,编写 F#代码的最佳实践之一是将函数写得尽可能小,并将行为提取到单独的函数中:
// A simple record for holding table names.
type TableName = {tableName : string}
// A discriminated union for the types of queries we're going to use.
type Queries =
| LoadUserTables
// Returns a SQL statement for the desired query.
let getSqlQuery query =
match query with
| LoadUserTables -> "select s.name + '.' + o.name as table_name from sys.objects o left join sys.schemas s on s.schema_id = o.schema_id where type_desc = 'USER_TABLE'"
// Returns a SqlCommand instance.
let getCommand (conn : SqlConnection) query =
let cmd = conn.CreateCommand()
cmd.CommandText <- getSqlQuery query
cmd
// Reads all records.
let readTableNames (cmd : SqlCommand) =
let rec read (reader : SqlDataReader) list =
match reader.Read() with
| true -> read reader ({tableName = (reader.[0]).ToString()} :: list)
| false -> list
use reader = cmd.ExecuteReader()
read reader []
// Returns the list of tables in the database specified by the connection.
let getTables (conn : SqlConnection) =
getCommand conn LoadUserTables |> readTableNames |> List.rev
在前面的代码中,我们创建了:
- 一个有区别的联合,这样我们就可以为我们的 SQL 语句建立一个查找表。
- 给定所需类型,返回所需 SQL 语句的函数。这可以很容易地用例如从 XML 文件中查找来代替。
- 给定连接和查询名称,返回 SqlCommand 实例的函数。
- 实现递归读取器的函数。
getTables
函数,返回表名列表。
在我们以读取数据库的用户表结束之前,让我们编写一个函数,将 F#列表映射到一个System.Collections.Generic.List<string>
,适合 C#使用。同样,通过包含FSharp.Core
程序集,我们可以在 C#中直接使用 F#类型。这种转换只是为了方便起见。这里有一个简单的单元测试:
[<Fact>]
member s.toGenericList() =
use conn = s.CreateConnection()
let tables = BackEnd.getTables conn
let genericList = BackEnd.tableListToGenericList tables
Assert.Equal(genericList.Count, tables.Length)
Assert.True(genericList.[0] = tables.[0].tableName)
下面是实现:
// Convert a TableName : list to a System.Collection.Generic.List.
let tableListToGenericList list =
let genericList = new System.Collections.Generic.List<string>()
List.iter(fun (e) -> genericList.Add(e.tableName)) list
genericList
接下来,我们希望能够读取任何表的数据,将数据本身的元组作为通用记录列表和列名列表返回。下面的例子是我们的单元测试:
[<Fact>]
member s.LoadTable() =
use conn = s.CreateConnection()
let data = BackEnd.loadData conn "Person.Person"
Assert.True((fst data).Length > 0)
// Assert something we know about the schema.
Assert.True(List.exists(fun (t) -> (t.columnName = "FirstName")) (snd data))
// Verify the correct order of the schema.
Assert.True((snd data).[0].columnName = "BusinessEntityID");
为了实现这一点,我们现在需要扩展我们的 SQL 查询查找:
type Queries =
| LoadUserTables
| LoadTableData
| LoadTableSchema
此外,返回查询的函数需要更聪明,根据可选的表名参数用表名代替某些查询:
let getSqlQuery query (tableName : string option) =
match query with
| LoadUserTables -> "select s.name + '.' + o.name as table_name from sys.objects o left join sys.schemas s on s.schema_id = o.schema_id where type_desc = 'USER_TABLE'"
| LoadTableData ->
match tableName with
| Some name -> "select * from " + name
| None -> failwith "table name is required."
| LoadTableSchema ->
match tableName with
| Some name ->
let schemaAndName = name.Split('.')
"select COLUMN_NAME from information_schema.columns where table_name = '" + schemaAndName.[1] + "' AND table_schema='" + schemaAndName.[0] + "' order by ORDINAL_POSITION"
| None -> failwith "table name is required."
接下来,我们需要能够读取表格数据:
// Returns all the fields for a record.
let getFieldValues (reader : SqlDataReader) =
let objects = Array.create reader.FieldCount (new Object())
reader.GetValues(objects) |> ignore
Array.toList objects
// Returns a list of rows populated with an array of field values.
let readTableData (cmd : SqlCommand) =
let rec read (reader : SqlDataReader) list =
match reader.Read() with
| true -> read reader (getFieldValues reader :: list)
| false -> list
use reader = cmd.ExecuteReader()
read reader []
我们需要能够读取表的模式(注意需要显式转换为Object[]
):
let readTableSchema (cmd : SqlCommand) =
let schema = readTableData cmd
List.map(fun (c) -> {columnName = (c : Object[]).[0].ToString()}) schema |> List.rev
最后,我们有一个加载数据的函数,返回一个数据元组及其模式:
let loadData (conn : SqlConnection) tableName =
let data = getCommand conn LoadTableData (Some tableName) |> readTableData
let schema = (getCommand conn LoadTableSchema (Some tableName) |> readTableSchema)
(data, schema)
现在,如果你注意的话,你会注意到功能readTableNames
和readTableData
几乎是一样的。唯一的区别是列表是如何构建的。让我们将它重构为一个单独的读取器,在其中传递解析每一行所需的函数,以创建最终的列表:
// Reads all the records and parses them as specified by the rowParser parameter.
let readData rowParser (cmd : SqlCommand) =
let rec read (reader : SqlDataReader) list =
match reader.Read() with
| true -> read reader (rowParser reader :: list)
| false -> list
use reader = cmd.ExecuteReader()
read reader []
我们现在有了一个更通用的函数,它允许我们指定如何解析一行。我们现在创建一个返回TableName
记录的函数:
// Returns a table name from the current reader position.
let getTableNameRecord (reader : SqlDataReader) =
{tableName = (reader.[0]).ToString()}
这允许我们重构getTables
:
// Returns the list of tables in the database specified by the connection.
let getTables (conn : SqlConnection) =
getCommand conn LoadUserTables None |> readData getTableNameRecord
我们还重构了读取表模式和表的记录:
// Returns a list of ColumnName records representing the field names of a table.
let readTableSchema (cmd : SqlCommand) =
let schema = readData getFieldValues cmd
List.map(fun (c) -> {columnName = (c : List<Object>).[0].ToString()}) schema |> List.rev
// Returns a tuple of table data and the table schema.
let loadData (conn : SqlConnection) tableName =
let data = getCommand conn LoadTableData (Some tableName) |> readData getFieldValues
let schema = (getCommand conn LoadTableSchema (Some tableName) |> readTableSchema)
(data, schema)
这里我们利用了部分函数应用——我们已经很容易地重构了读取器,使其在解析每一行时更加通用。这花了大约五分钟的时间,通过我们现有的单元测试,我们能够验证我们的更改没有破坏任何东西。
除非你想在你的 C#项目中包含FSharp.Core
程序集,否则你会想把任何返回到 C#的东西转换成。NET“命令式”类。它只是让事情变得更容易。当然,我们可以通过一个DataSet
阅读器将记录直接加载到DataTable
中,但是我们使用的过程更能说明保持 F#(此外,我们通常不会加载整个表,而是实现某种分页方案。)
因此,最后一步是用我们的 F#行和表模式信息填充DataTable
,我们将在 F#中完成。首先,我们应该创建一个单元测试来确保我们的DataTable
的某些东西:
[<Fact>]
member s.ToDataTable() =
use conn = s.CreateConnection()
let data = BackEnd.loadData conn "Person.Person"
let dataTable = BackEnd.toDataTable data
Assert.IsType<DataTable>(dataTable) |> ignore
Assert.Equal(dataTable.Columns.Count, (snd data).Length)
Assert.True(dataTable.Columns.[0].ColumnName = "BusinessEntityID")
Assert.Equal(dataTable.Rows.Count, (fst data).Length)
F#中的实现包括三个函数:设置列、填充行,以及调用这两个步骤并返回DataTable
实例的函数:
// Populates a DataTable given a ColumnName List, returning
// the DataTable instance.
let setupColumns (dataTable : DataTable) schema =
let rec addColumn colList =
match colList with
| hd::tl ->
let newColumn = new DataColumn()
newColumn.ColumnName <- hd.columnName
dataTable.Columns.Add(newColumn)
addColumn tl
| [] -> dataTable
addColumn schema
// Populates the rows of a DataTable from a data list.
let setupRows data (dataTable : DataTable) =
// Rows:
let rec addRow dataList =
match dataList with
| hd::tl ->
let dataRow = dataTable.NewRow()
// Columns:
let rec addFieldValue (index : int) fieldList =
match fieldList with
| fhd::ftl ->
dataRow.[index] <- fhd
addFieldValue (index + 1) ftl
| [] -> ()
addFieldValue 0 hd
dataTable.Rows.InsertAt(dataRow, 0)
addRow tl
| [] -> dataTable
addRow data
// Return a DataTable populated from our (data, schema) tuple.
let toDataTable (data, schema) =
let dataTable = new DataTable()
setupColumns dataTable schema |> setupRows data
我们所有的单元测试都通过了!
图 5:成功的单元测试
现在我们准备写前端了。我们将创建两个GridListControl
控件的简单布局:
图 6:两个网格列表控件
代码隐藏包括加载表列表,调用我们的 F#代码来获取表列表:
protected void InitializeTableList()
{
List<string> tableList;
using (var conn = BackEnd.openConnection(connectionString))
{
tableList = BackEnd.getTablesAsGenericList(conn);
}
var tableNameList = new List<TableName>();
tableList.ForEach(t => tableNameList.Add(new TableName() { Name = t }));
gridTableList.DisplayMember = "Name";
gridTableList.ValueMember = "Name";
gridTableList.DataSource = tableNameList;
}
当选择一个表时,我们从 F#代码中获取DataTable
并设置网格的DataSource
属性。
private void gridTableList_SelectedValueChanged(object sender, EventArgs e)
{
if (gridTableList.SelectedValue != null)
{
string tableName = gridTableList.SelectedValue.ToString();
DataTable dt;
using (var conn = BackEnd.openConnection(connectionString))
{
Cursor = Cursors.WaitCursor;
var data = BackEnd.loadData(conn, tableName);
dt = BackEnd.toDataTable(data.Item1, data.Item2);
Cursor = Cursors.Arrow;
}
gridTableData.DataSource = dt;
}
}
这给了我们前端用户界面进程和后端数据库交互之间的良好分离,从而产生了一个简单的数据库导航器。
图 7:完整的数据库导航器