F#奇妙游(33):动态执行F#代码
问题
起因
最近正在用F#开发一个应用系统,其中核心的问题是建立一个系统,这个系统有串联和并联的分系统嵌套组成,所以构成的样子就好比说是:
如果把这个系统描述可能需要序列化,也就是存在硬盘上。
这个系统的编程下面再单独写一篇,这里只考虑序列化的问题。从内存对象到可阅读的图形表达我用输出mermaid就很轻松解除了。那么硬盘那边怎么办?采用什么样的格式?如何描述?
我想来想去,感觉能不能直接用脚本?反正我们内存的对象也是调用构造函数产生的,我们直接把数据用F#脚本写起来不就行了?
载入脚本
到这个时候我也开始佩服我自己了。我们可以在程序里提供接口,让用户直接写表达式,大不了搞点DSL对于F#这样的语言还不是小意思!
想想也不复杂!就是以下三步:
- 把脚本字符串生成的脚本文件运行时编译一下,得到一个Assembly;
- 把这个Assembly中的函数找到;
- 运行我们新鲜编好的函数。
参考
这个过程有两个帖子,都是英文的。这两个帖子第一个是主要信息来源,但是两个帖子关于编译脚本的部分都是过时的。
引用其他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"
- 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
- FSharp.Compiler.Service, 43.7.400
Ok args_test, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
这里唯一要注意的就是,我们默认的输出文件在当前目录,所以参数的第一个是带有完整的路径或者相对路径的。而第二个DLL文件,就只有文件名。
编译脚本字符串
按照我们前面的规划,我们需要的是把字符串编译到程序中,所以还有一个步骤,就是输出脚本文件。
在这个函数里,我们输入有三个:
- 模块的名称,也就是脚本文件的名称,这是个约定,一个fsx脚本编译后,所有的值都在文件名对应的域中间;
- 第二个就是这个文件需要调用的其他DLL文件,这里是一个列表,默认这些DLL都在运行的当前目录下;
- 第三个就是脚本字符串。
这个函数的逻辑也非常简单,把所有的额外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
结论
上面就是把代码字符串变为可执行的函数所需要的全部知识,能玩出什么花样来就看自己了。
FSharp.Compiler.CodeAnalysis.FSharpChecker
提供了编译方法Compile
;System.Reflection.Assembly
提供了载入DLL的方法;- 值得注意的是文件的位置。