F#奇妙游(33):动态执行F#代码

发布时间 2023-09-13 13:25:22作者: 大福是小强

问题

起因

最近正在用F#开发一个应用系统,其中核心的问题是建立一个系统,这个系统有串联和并联的分系统嵌套组成,所以构成的样子就好比说是:

graph LR ss#-270763029[A] ss#-952472382[B1] ss#1904380480[B2] ss#-50630250[B3] ss#950184885[C] ss#-270763029--> ss#-952472382 ss#-270763029--> ss#1904380480 ss#-270763029--> ss#-50630250 ss#-952472382--> ss#950184885 ss#1904380480--> ss#950184885 ss#-50630250--> ss#950184885

如果把这个系统描述可能需要序列化,也就是存在硬盘上。

graph LR O[人类] --> A[硬盘文件] --> B[内存对象] --> C[可阅读的图形表达]

这个系统的编程下面再单独写一篇,这里只考虑序列化的问题。从内存对象到可阅读的图形表达我用输出mermaid就很轻松解除了。那么硬盘那边怎么办?采用什么样的格式?如何描述?

我想来想去,感觉能不能直接用脚本?反正我们内存的对象也是调用构造函数产生的,我们直接把数据用F#脚本写起来不就行了?

载入脚本

到这个时候我也开始佩服我自己了。我们可以在程序里提供接口,让用户直接写表达式,大不了搞点DSL对于F#这样的语言还不是小意思!

想想也不复杂!就是以下三步:

  1. 把脚本字符串生成的脚本文件运行时编译一下,得到一个Assembly;
  2. 把这个Assembly中的函数找到;
  3. 运行我们新鲜编好的函数。

参考

这个过程有两个帖子,都是英文的。这两个帖子第一个是主要信息来源,但是两个帖子关于编译脚本的部分都是过时的。

引用其他DLL

我们考虑也很简单。首先,有一部分需要的库文件单独生成一个dll,我们叫做Model.dll。

namespace Model

type ExampleRecord = { Forename: string; Surname: string }

module My = 
    let addTwo x y = x + y

这里用了简单的定义一个记录,定义一个函数,在脚本中,我们就需要调用这两个值。怎么产生这个DLL是很简单的,前面讲过。

dotnet new classlib -n libname -lang F#
dotnet build
dotnet add current_project reference libname

编译脚本

引用FSharp.Compiler.Services

编译脚本非常简单,只需要把FSharp.Compiler.Services包加到工程里面,或者在脚本fsx文件载入。

#r "nuget: FSharp.Compiler.Service, 43.7.400"
Installed Packages
  • FSharp.Compiler.Service, 43.7.400

编译脚本文件

编译脚本需要用到的是FSharp.Compiler.CodeAnalysis.FSharpChecker类型。这个类现在只有一个Compile函数,而不是前面两个帖子和网上写的CompileToDynamicAssembly。同"-o"和"-a"分别指定输出的文件和输入的文件,就能完成编译。

编译后根据返回的exitCode来判断编译结果,如果为0,那么就是成功编译,把dll文件用Assemply.LoadFile载入进来。这里可以看到,我们默认dll文件是在当前目录下。如果结果不是1,那么errors就包含了编译错误信息。

这里我们采用System.Reflection.Assembly把输出的DLL载入到程序中,就算完成了第一步。

#r "nuget: FSharp.Compiler.Service, 43.7.400"
open FSharp.Compiler.CodeAnalysis
open System.Reflection

let compileAndLoad script outputAssemblyName =
    let checker = FSharpChecker.Create()

    let errors, exitCode =
        checker.Compile([| "-o"; outputAssemblyName; "-a"; script |])
        |> Async.RunSynchronously

    match exitCode with
    | 0 -> Ok(Assembly.LoadFile(Path.Combine(Directory.GetCurrentDirectory(), outputAssemblyName)))
    | _ ->
        errors
        |> Array.map (fun error -> $"{error.StartLine}: {error.Message}")
        |> String.concat ("\n")
        |> Error

let ret = compileAndLoad "./args_test.fsx" "args_test.dll"
printfn "%A" ret
Installed Packages
  • FSharp.Compiler.Service, 43.7.400
Ok args_test, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null

这里唯一要注意的就是,我们默认的输出文件在当前目录,所以参数的第一个是带有完整的路径或者相对路径的。而第二个DLL文件,就只有文件名。

编译脚本字符串

按照我们前面的规划,我们需要的是把字符串编译到程序中,所以还有一个步骤,就是输出脚本文件。

在这个函数里,我们输入有三个:

  1. 模块的名称,也就是脚本文件的名称,这是个约定,一个fsx脚本编译后,所有的值都在文件名对应的域中间;
  2. 第二个就是这个文件需要调用的其他DLL文件,这里是一个列表,默认这些DLL都在运行的当前目录下;
  3. 第三个就是脚本字符串。

这个函数的逻辑也非常简单,把所有的额外DLL文件用#r "xxx.dll"载入到脚本中;然后就是脚本本身,存到临时文件夹,输出的DLL就放在当前文件夹中,注意这里把脚本文件的路径都去掉了,把名称拿出来替换后缀为dll。然后调用compileAndLoad完成工作。

let compileString moduleName extraAssemblies (script: string) =
    let scriptName = Path.GetTempPath() + $"{moduleName}.fsx"
    let outputAssemblyName = Path.ChangeExtension(Path.GetFileName(scriptName), ".dll")
    let s = script.TrimStart()

    let scriptToCompile =
        extraAssemblies
        |> List.map (fun dll -> Path.Combine(Directory.GetCurrentDirectory(), dll))
        |> List.map (fun dll -> $"#r @\"{dll}\"")
        |> (fun l -> l @ [ $"{s}" ])
        |> (fun t -> String.Join("\n", t))

    File.WriteAllText(scriptName, scriptToCompile)
    compileAndLoad scriptName outputAssemblyName

let ret = compileString "Hello" [] """
let f0 () = 1 + 2
let f1 x = x + 1
let f2 x y = x + y
"""

printfn "%A" ret
Ok Hello, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null

对于James Randall (jamesdrandall.com)的例子,那就更简单了。

let compileScript name =
    let script = $"./scripts/{name}.fsx"
    let outputAssemblyName = Path.ChangeExtension(Path.GetFileName(script), ".dll")
    compileAndLoad script outputAssemblyName

let ret = compileScript "partial"

printfn "%A" ret
Ok partial, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null

如果脚本已经写好,那么直接构造相对文件的名称或者绝对文件地址,调用compileAndLoad就能完成。

找到函数

在一个载入的DLL文件,也就是Assembly.LoadFile(path: string) : Assembly的返回值中,进行信息提取。

这个函数把完整的名称用最后一个.分割成两个部分,第一个部分是域或者类型的名称,在Assembly.GetTypes()中间查找;第二个部分就是函数的名称,就在第一步得到的后选中找采用Type.GetMethod(name: string, bindingAttr: BindingFlags) : MethodInfo来找。这里,就只查找静态或者公共函数。如果成功找到,最后返回的就是MethodInfo对象。

let getMemberInfo (name: string) (assembly: Assembly) =
    let fqTypeName, memberName =
        let splitIndex = name.LastIndexOf(".")
        name[0 .. splitIndex - 1], name[splitIndex + 1 ..]

    let candidates =
        assembly.GetTypes()
        |> Seq.where (fun t -> t.FullName = fqTypeName)
        |> Seq.toList

    match candidates with
    | [ t ] ->
        match t.GetMethod(memberName, BindingFlags.Static ||| BindingFlags.Public) with
        | null -> Error "Member not found"
        | memberInfo -> Ok memberInfo
    | [] -> Error "Parent type not found"
    | _ -> Error "Multiple candidate parent types found"

let mi = getMemberInfo "Hello.f1" (Assembly.Load("Hello"))

printfn "%A" mi

Ok Int32 f1(Int32)

拿到这个MethodInfo之后,就可提取Delegate

open System.Linq.Expressions

let extractor<'r> name assembly parameters =
    match getMemberInfo name assembly with
    | Ok memberInfo ->
        try
            let lambda =
                let expression =
                    if (typeof<'r> = typeof<unit>) then
                        Expression.Block(
                            Expression.Call(memberInfo, parameters |> Array.map (fun param -> param :> Expression)),
                            Expression.Constant((), typeof<'r>)
                        )
                        :> Expression
                    else
                        Expression.Convert(
                            Expression.Call(memberInfo, parameters |> Array.map (fun param -> param :> Expression)),
                            typeof<'r>
                        )
                        :> Expression

                Expression.Lambda(expression, parameters)

            let systemFunc = lambda.Compile()
            systemFunc |> Ok
        with ex ->
            Error $"{ex.GetType().Name}: {ex.Message}"
    | Error error -> Error error

let f = extractor<int> "Hello.f1" (Assembly.Load("Hello")) [|Expression.Parameter(typeof<int>)|]

printfn "%A" f
Ok System.Func`2[System.Int32,System.Int32]

调用函数

在上面获得Delegate函数的基础上,就可以很容易地实现调用函数的功能。

例如,把System.Func变成F#函数。就比如,一个没有输入参数的函数。其类型就是unit -> 'r。用下面的提取函数即可。

let extractFunction0<'r> name (assembly: Assembly) : Result<unit -> 'r, string> =
    let parameters = [||]
    let systemFuncResult = extractor<'r> name assembly parameters

    systemFuncResult
    |> Result.map (fun systemFunc -> systemFunc :?> Func<'r> |> FuncConvert.FromFunc)

let assembly = Assembly.Load("Hello")
let ret = extractFunction0<int> "Hello.f0" assembly

printfn "%A" ret

Ok <fun:extractFunction0@6-1>

类似的,也容易得到不同参数个数的F#函数。

let extractFunction1<'p1, 'r> name (assembly: Assembly) : Result<'p1 -> 'r, string> =
    let parameters = [| Expression.Parameter(typeof<'p1>) |]
    let systemFuncResult = extractor<'r> name assembly parameters

    systemFuncResult
    |> Result.map (fun systemFunc -> systemFunc :?> Func<'p1, 'r> |> FuncConvert.FromFunc)

let extractFunction2<'p1, 'p2, 'r> name (assembly: Assembly) : Result<'p1 -> 'p2 -> 'r, string> =
    let parameters =
        [| Expression.Parameter(typeof<'p1>); Expression.Parameter(typeof<'p2>) |]

    let systemFuncResult = extractor<'r> name assembly parameters

    systemFuncResult
    |> Result.map (fun systemFunc -> systemFunc :?> Func<'p1, 'p2, 'r> |> FuncConvert.FromFunc)

let extractFunction3<'p1, 'p2, 'p3, 'r> name (assembly: Assembly) : Result<'p1 -> 'p2 -> 'p3 -> 'r, string> =
    let parameters =
        [| Expression.Parameter(typeof<'p1>)
           Expression.Parameter(typeof<'p2>)
           Expression.Parameter(typeof<'p3>) |]

    let systemFuncResult = extractor<'r> name assembly parameters

    systemFuncResult
    |> Result.map (fun systemFunc -> systemFunc :?> Func<'p1, 'p2, 'p3, 'r> |> FuncConvert.FromFunc)

在此基础上,可以很容易编制调用函数的代码。例如调用上面Hello.dll中的f0

let call<'r> assembly name =
    assembly
    |> extractFunction0<'r> name
    |> (function
        | Ok f -> f ()
        | Error error -> failwith error)

let assembly = Assembly.Load("Hello")

let ret = call<int> assembly "Hello.f0"

printfn "%A" ret
3
let call1<'p, 'r> assembly name =
    assembly
    |> extractFunction1<'p, 'r> name
    |> (function
        | Ok f -> f
        | Error error -> failwith error)

let assembly = Assembly.Load("Hello")

let ret = call1<int, int> assembly "Hello.f1"

printfn "%A" (ret 20)
21

结论

上面就是把代码字符串变为可执行的函数所需要的全部知识,能玩出什么花样来就看自己了。

  1. FSharp.Compiler.CodeAnalysis.FSharpChecker提供了编译方法Compile
  2. System.Reflection.Assembly提供了载入DLL的方法;
  3. 值得注意的是文件的位置。