diff --git a/benchmarks/FSharp.SystemTextJson.Benchmarks/Program.fs b/benchmarks/FSharp.SystemTextJson.Benchmarks/Program.fs index d1db159..6d97502 100644 --- a/benchmarks/FSharp.SystemTextJson.Benchmarks/Program.fs +++ b/benchmarks/FSharp.SystemTextJson.Benchmarks/Program.fs @@ -22,6 +22,12 @@ type TestRecord = thing: bool option time: System.DateTimeOffset } +[] +type TestStructRecord = + { name: string + thing: bool voption + time: System.DateTimeOffset } + type SimpleClass() = member val Name: string = null with get, set member val Thing: bool option = None with get, set @@ -34,22 +40,31 @@ type ArrayTestBase<'t>(instance: 't) = options - [] + [] member val ArrayLength = 0 with get, set member val InstanceArray = [||] with get, set + + member val Serialized = "" with get, set [] member this.InitArray () = this.InstanceArray <- Array.replicate this.ArrayLength instance + this.Serialized <- this.InstanceArray |> JsonConvert.SerializeObject + + [] + member this.Serialize_Newtonsoft () = JsonConvert.SerializeObject this.InstanceArray [] - member this.Newtonsoft () = JsonConvert.SerializeObject this.InstanceArray + member this.Serialize_SystemTextJson () = System.Text.Json.JsonSerializer.Serialize(this.InstanceArray, systemTextOptions) [] - member this.SystemTextJson () = System.Text.Json.JsonSerializer.Serialize(this.InstanceArray, systemTextOptions) + member this.Deserialize_Newtonsoft () = JsonConvert.DeserializeObject<'t[]> this.Serialized -let recordInstance = + [] + member this.Deserialize_SystemTextJson () = System.Text.Json.JsonSerializer.Deserialize<'t[]>(this.Serialized, systemTextOptions) + +let recordInstance : TestRecord = { name = "sample" thing = Some true time = System.DateTimeOffset.UnixEpoch.AddDays(200.) } @@ -58,6 +73,14 @@ let recordInstance = type Records () = inherit ArrayTestBase(recordInstance) +let recordStructInstance : TestStructRecord = + { name = "sample" + thing = ValueSome true + time = System.DateTimeOffset.UnixEpoch.AddDays(200.) } + +type StructRecords () = + inherit ArrayTestBase(recordStructInstance) + type Classes() = inherit ArrayTestBase(SimpleClass(Name = "sample", Thing = Some true, Time = DateTimeOffset.UnixEpoch.AddDays(200.))) @@ -97,7 +120,7 @@ let config = .With(ExecutionValidator.FailOnError) let defaultSwitch () = - BenchmarkSwitcher([| typeof; typeof; typeof |]) + BenchmarkSwitcher([| typeof; typeof; typeof; typeof |]) [] diff --git a/benchmarks/FSharp.SystemTextJson.Benchmarks/paket.references b/benchmarks/FSharp.SystemTextJson.Benchmarks/paket.references index b2da4c2..56cdfa6 100644 --- a/benchmarks/FSharp.SystemTextJson.Benchmarks/paket.references +++ b/benchmarks/FSharp.SystemTextJson.Benchmarks/paket.references @@ -1,3 +1,4 @@ FSharp.Core BenchmarkDotNet -Newtonsoft.Json \ No newline at end of file +Newtonsoft.Json +BenchmarkDotNet.Diagnostics.Windows \ No newline at end of file diff --git a/build.fsx b/build.fsx index 28ab5d7..367e050 100644 --- a/build.fsx +++ b/build.fsx @@ -73,13 +73,19 @@ Target.create "Test" (fun _ -> /// This target doesn't need a dependency chain, because the benchmarks actually wrap and build the referenced /// project(s) as part of the run. -Target.create "Benchmark" (fun _ -> - DotNet.exec (fun o -> { o with - WorkingDirectory = Paths.benchmarks } ) "run" "-c release --filter \"*\"" +Target.create "Benchmark" (fun p -> + let args = p.Context.Arguments + seq { + yield! ["-p"; Paths.benchmarks; "-c"; "release"; "--"] + if not (List.contains "-f" args || List.contains "--filter" args) then + yield! ["--filter"; "*"] + yield! args + } + |> Args.toWindowsCommandLine + |> DotNet.exec id "run" |> fun r -> - if r.OK - then () - else failwithf "Benchmarks failed with code %d:\n%A" r.ExitCode r.Errors + if not r.OK then + failwithf "Benchmarks failed with code %d:\n%A" r.ExitCode r.Errors ) Target.create "All" ignore diff --git a/paket.dependencies b/paket.dependencies index d78ec4c..53974bc 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -2,6 +2,7 @@ frameworks netstandard2.0, netcoreapp3.0 storage none source https://api.nuget.org/v3/index.json nuget FsCheck.XUnit +nuget BenchmarkDotNet.Diagnostics.Windows nuget FSharp.Core ~> 4.7.0 nuget Microsoft.NET.Test.Sdk ~> 16.3.0 nuget Microsoft.SourceLink.GitHub prerelease copy_local:true diff --git a/paket.lock b/paket.lock index e1035ac..e48268f 100644 --- a/paket.lock +++ b/paket.lock @@ -20,6 +20,9 @@ NUGET System.Xml.XmlSerializer (>= 4.3) System.Xml.XPath.XmlDocument (>= 4.3) BenchmarkDotNet.Annotations (0.11.5) + BenchmarkDotNet.Diagnostics.Windows (0.11.5) + BenchmarkDotNet (>= 0.11.5) + Microsoft.Diagnostics.Tracing.TraceEvent (>= 2.0.34) CommandLineParser (2.6) FsCheck (2.14) FSharp.Core (>= 4.2.3) diff --git a/src/FSharp.SystemTextJson/FSharp.SystemTextJson.fsproj b/src/FSharp.SystemTextJson/FSharp.SystemTextJson.fsproj index 9ef1cab..86a900f 100644 --- a/src/FSharp.SystemTextJson/FSharp.SystemTextJson.fsproj +++ b/src/FSharp.SystemTextJson/FSharp.SystemTextJson.fsproj @@ -8,6 +8,7 @@ + diff --git a/src/FSharp.SystemTextJson/Record.Reflection.fs b/src/FSharp.SystemTextJson/Record.Reflection.fs new file mode 100644 index 0000000..0e05313 --- /dev/null +++ b/src/FSharp.SystemTextJson/Record.Reflection.fs @@ -0,0 +1,145 @@ +namespace System.Text.Json.Serialization + +open System +open System.Reflection +open FSharp.Reflection +open System.Reflection.Emit +open System.Text.Json + +type internal RefobjFieldGetter<'Record, 'Field> = Func<'Record, 'Field> +type internal StructFieldGetter<'Record, 'Field> = delegate of inref<'Record> -> 'Field + +type internal RefobjSerializer<'Record> = Action +type internal StructSerializer<'Record> = delegate of Utf8JsonWriter * inref<'Record> * JsonSerializerOptions -> unit + +[] +type internal Serializer<'Record> = + | SStruct of s: StructSerializer<'Record> + | SRefobj of n: RefobjSerializer<'Record> + +type internal RefobjFieldSetter<'Record, 'Field> = Action<'Record, 'Field> +type internal StructFieldSetter<'Record, 'Field> = delegate of byref<'Record> * 'Field -> unit + +type internal RefobjDeserializer<'Record> = delegate of byref * 'Record * JsonSerializerOptions -> unit +type internal StructDeserializer<'Record> = delegate of byref * byref<'Record> * JsonSerializerOptions -> unit + +[] +type internal Deserializer<'Record> = + | DStruct of s: StructDeserializer<'Record> + | DRefobj of n: RefobjDeserializer<'Record> + +type internal RecordField<'Record> = + { + Name: string + Type: Type + Ignore: bool + Serialize: Serializer<'Record> + Deserialize: Deserializer<'Record> + } + +module internal RecordReflection = + + let private name (p: PropertyInfo) = + match p.GetCustomAttributes(typeof, true) with + | [| :? JsonPropertyNameAttribute as name |] -> name.Name + | _ -> p.Name + + let private isIgnore (p: PropertyInfo) = + p.GetCustomAttributes(typeof, true) + |> Array.isEmpty + |> not + + let private deserializer<'Record, 'Field> (f: FieldInfo) = + let setter = + let dynMethod = + new DynamicMethod( + f.Name, + typeof, + [| + (if f.DeclaringType.IsValueType + then typeof<'Record>.MakeByRefType() + else typeof<'Record>) + f.FieldType + |], + typedefof>.Module, + skipVisibility = true + ) + let gen = dynMethod.GetILGenerator() + gen.Emit(OpCodes.Ldarg_0) + gen.Emit(OpCodes.Ldarg_1) + gen.Emit(OpCodes.Stfld, f) + gen.Emit(OpCodes.Ret) + dynMethod + if f.DeclaringType.IsValueType then + let setter = setter.CreateDelegate(typeof>) :?> StructFieldSetter<'Record, 'Field> + StructDeserializer<'Record>(fun reader record options -> + let value = JsonSerializer.Deserialize<'Field>(&reader, options) + setter.Invoke(&record, value)) + |> DStruct + else + let setter = setter.CreateDelegate(typeof>) :?> RefobjFieldSetter<'Record, 'Field> + RefobjDeserializer<'Record>(fun reader record options -> + let value = JsonSerializer.Deserialize<'Field>(&reader, options) + setter.Invoke(record, value)) + |> DRefobj + + let private serializer<'Record, 'Field> (f: FieldInfo) = + let getter = + let dynMethod = + new DynamicMethod( + f.Name, + f.FieldType, + [| + (if f.DeclaringType.IsValueType + then typeof<'Record>.MakeByRefType() + else typeof<'Record>) + |], + typedefof>.Module, + skipVisibility = true + ) + let gen = dynMethod.GetILGenerator() + gen.Emit(OpCodes.Ldarg_0) + gen.Emit(OpCodes.Ldfld, f) + gen.Emit(OpCodes.Ret) + dynMethod + if f.DeclaringType.IsValueType then + let getter = getter.CreateDelegate(typeof>) :?> StructFieldGetter<'Record, 'Field> + StructSerializer<'Record>(fun writer record options -> + let v = getter.Invoke(&record) + JsonSerializer.Serialize<'Field>(writer, v, options) + ) + |> SStruct + else + let getter = getter.CreateDelegate(typeof>) :?> RefobjFieldGetter<'Record, 'Field> + RefobjSerializer<'Record>(fun writer record options -> + let v = getter.Invoke(record) + JsonSerializer.Serialize<'Field>(writer, v, options) + ) + |> SRefobj + + let private thisModule = typedefof>.Assembly.GetType("System.Text.Json.Serialization.RecordReflection") + + let fields<'Record> () = + let recordTy = typeof<'Record> + let fields = recordTy.GetFields(BindingFlags.Instance ||| BindingFlags.NonPublic) + let props = FSharpType.GetRecordFields(recordTy, true) + (fields, props) + ||> Array.map2 (fun f p -> + let serializer = + thisModule.GetMethod("serializer", BindingFlags.Static ||| BindingFlags.NonPublic) + .MakeGenericMethod(recordTy, p.PropertyType) + .Invoke(null, [|f|]) + :?> Serializer<'Record> + let deserializer = + thisModule.GetMethod("deserializer", BindingFlags.Static ||| BindingFlags.NonPublic) + .MakeGenericMethod(recordTy, p.PropertyType) + .Invoke(null, [|f|]) + :?> Deserializer<'Record> + { + Name = name p + Type = p.PropertyType + Ignore = isIgnore p + Serialize = serializer + Deserialize = deserializer + } : RecordField<'Record> + ) diff --git a/src/FSharp.SystemTextJson/Record.fs b/src/FSharp.SystemTextJson/Record.fs index 81ad8fc..8284d2c 100644 --- a/src/FSharp.SystemTextJson/Record.fs +++ b/src/FSharp.SystemTextJson/Record.fs @@ -1,49 +1,40 @@ namespace System.Text.Json.Serialization open System +open System.Runtime.Serialization open System.Text.Json open FSharp.Reflection - -type internal RecordProperty = - { - Name: string - Type: Type - Ignore: bool - } +open System.Collections.Generic type JsonRecordConverter<'T>() = inherit JsonConverter<'T>() - static let fieldProps = - FSharpType.GetRecordFields(typeof<'T>, true) - |> Array.map (fun p -> - let name = - match p.GetCustomAttributes(typeof, true) with - | [| :? JsonPropertyNameAttribute as name |] -> name.Name - | _ -> p.Name - let ignore = - p.GetCustomAttributes(typeof, true) - |> Array.isEmpty - |> not - { Name = name; Type = p.PropertyType; Ignore = ignore } - ) + static let ty = typeof<'T> + + static let fields = RecordReflection.fields<'T>() + + static let fieldIndices = Dictionary(StringComparer.InvariantCulture) + static do fields |> Array.iteri (fun i f -> + fieldIndices.[f.Name] <- f) static let expectedFieldCount = - fieldProps + fields |> Seq.filter (fun p -> not p.Ignore) |> Seq.length - static let ctor = FSharpValue.PreComputeRecordConstructor(typeof<'T>, true) - - static let dector = FSharpValue.PreComputeRecordReader(typeof<'T>, true) + static let ctor = + if ty.IsValueType then + fun () -> Unchecked.defaultof<'T> + else + fun () -> FormatterServices.GetUninitializedObject(ty) :?> 'T static let fieldIndex (reader: byref) = let mutable found = ValueNone let mutable i = 0 - while found.IsNone && i < fieldProps.Length do - let p = fieldProps.[i] + while found.IsNone && i < fields.Length do + let p = fields.[i] if reader.ValueTextEquals(p.Name.AsSpan()) then - found <- ValueSome (struct (i, p)) + found <- ValueSome p else i <- i + 1 found @@ -52,7 +43,7 @@ type JsonRecordConverter<'T>() = if reader.TokenType <> JsonTokenType.StartObject then raise (JsonException("Failed to parse record type " + typeToConvert.FullName + ", expected JSON object, found " + string reader.TokenType)) - let fields = Array.zeroCreate fieldProps.Length + let mutable res = ctor() let mutable cont = true let mutable fieldsFound = 0 while cont && reader.Read() do @@ -61,24 +52,27 @@ type JsonRecordConverter<'T>() = cont <- false | JsonTokenType.PropertyName -> match fieldIndex &reader with - | ValueSome (i, p) when not p.Ignore -> + | ValueSome p when not p.Ignore -> fieldsFound <- fieldsFound + 1 - fields.[i] <- JsonSerializer.Deserialize(&reader, p.Type, options) + match p.Deserialize with + | DStruct p -> p.Invoke(&reader, &res, options) + | DRefobj p -> p.Invoke(&reader, res, options) | _ -> reader.Skip() | _ -> () if fieldsFound < expectedFieldCount then raise (JsonException("Missing field for record type " + typeToConvert.FullName)) - ctor fields :?> 'T + res override __.Write(writer, value, options) = writer.WriteStartObject() - (fieldProps, dector value) - ||> Array.iter2 (fun p v -> + for p in fields do if not p.Ignore then writer.WritePropertyName(p.Name) - JsonSerializer.Serialize(writer, v, options)) + match p.Serialize with + | SStruct p -> p.Invoke(writer, &value, options) + | SRefobj p -> p.Invoke(writer, value, options) writer.WriteEndObject() type JsonRecordConverter() = diff --git a/src/FSharp.SystemTextJson/paket.references b/src/FSharp.SystemTextJson/paket.references index f6cb595..79265da 100644 --- a/src/FSharp.SystemTextJson/paket.references +++ b/src/FSharp.SystemTextJson/paket.references @@ -1,3 +1,5 @@ FSharp.Core System.Text.Json framework: = netstandard2.0 -Microsoft.SourceLink.GitHub \ No newline at end of file +System.Reflection.Emit.Lightweight framework: = netstandard2.0 +System.Reflection.Emit.ILGeneration framework: = netstandard2.0 +Microsoft.SourceLink.GitHub diff --git a/tests/FSharp.SystemTextJson.Tests/Test.Record.fs b/tests/FSharp.SystemTextJson.Tests/Test.Record.fs index 30dc7ad..a8ab74b 100644 --- a/tests/FSharp.SystemTextJson.Tests/Test.Record.fs +++ b/tests/FSharp.SystemTextJson.Tests/Test.Record.fs @@ -25,7 +25,7 @@ module NonStruct = type B = { - bx: int + bx: uint32 by: string } @@ -35,11 +35,11 @@ module NonStruct = [] let ``deserialize via options`` () = let actual = JsonSerializer.Deserialize("""{"bx":1,"by":"b"}""", options) - Assert.Equal({bx=1;by="b"}, actual) + Assert.Equal({bx=1u;by="b"}, actual) [] let ``serialize via options`` () = - let actual = JsonSerializer.Serialize({bx=1;by="b"}, options) + let actual = JsonSerializer.Serialize({bx=1u;by="b"}, options) Assert.Equal("""{"bx":1,"by":"b"}""", actual) type C = @@ -50,7 +50,7 @@ module NonStruct = [] let ``deserialize nested`` () = let actual = JsonSerializer.Deserialize("""{"cx":{"bx":1,"by":"b"}}""", options) - Assert.Equal({cx={bx=1;by="b"}}, actual) + Assert.Equal({cx={bx=1u;by="b"}}, actual) [] let ``deserialize anonymous`` () =