1

I need to write a large data-set to a web-page (using server-side-rendering) that will be consumed by JavaScript components running in the page. This data consists of an array of objects for a data-grid component and a charting component (so each array element is a data-grid row).

I'd like to use use JavaScript object constructors instead of object literals for performance reasons (there are JIT compiler optimizations for objects using constructors and they use less space on-the-wire because property names are omitted). I can also use Date constructors natively too.

Here is how I'm doing it right now:

<script type="text/javascript">

function WeeklySalesRow( weekName, date, totalSales, profit, loss, turnover, deltaSalesPercentage, etc )
{
    this.weekName = weekName;
    this.date = date;
    this.totalSales = totalSales;
    this.profit = profit;
    this.loss = loss;
    // etc
}

var weeklySalesData = [
@{
    Boolean first = true;
    foreach( WeeklySalesRow row in this.Model.WeeklySalesData ) {
        if( !first ) this.WriteLine( "," ); first = false;
        this.Write( "new WeeklySalesRow( \"{0}\", new Date({1}), {2}, {3}, {4}, etc )", row.WeekName, row.Date.ToUnixTimestamp(), row.TotalSales, row.Profit, row.Loss, row.Turnover, etc );
    }
}
];

function onDomContentLoaded( e ) {

    var chartCompoennt = ...
    chartComponent.loadData( weeklySalesData );
}

</script>

Renders like this:

// [...]

var weeklySalesData = [
new WeeklySalesRow( "2018W1", new Date(1514764800), 1100, 200, 900, 50, 0.56, etc ),
new WeeklySalesRow( "2018W2", new Date(1515369600), 1200, 100, 800, 45, 0.80, etc ),
new WeeklySalesRow( "2018W3", new Date(1515974400), 1300, 50, 700, 65, 0.12, etc ),
new WeeklySalesRow( "2018W4", new Date(1516752000), 1400, 25, 600, 80, 0.45, etc ),
new WeeklySalesRow( "2018W5", new Date(1517443200), 1500, 12, 500, 90, 0.123, etc ),
// etc...
];

// [...]

Which is more succinct than:

var weeklySalesData = [
{ weekName: "2018W1", date: "2018-01-01", totalSales: 1100, profit: 200, loss: 900, turnover: 50, deltaSalesPercentage: 0.56, etc },
{ weekName: "2018W2", date: "2018-01-08", totalSales: 1200, profit: 100, loss: 800, turnover: 50, deltaSalesPercentage: 0.56, etc },
{ weekName: "2018W3", date: "2018-01-17", totalSales: 1300, profit: 50, loss: 700, turnover: 50, deltaSalesPercentage: 0.56, etc },
{ weekName: "2018W4", date: "2018-01-23", totalSales: 1400, profit: 25, loss: 600, turnover: 50, deltaSalesPercentage: 0.56, etc },
{ weekName: "2018W5", date: "2018-02-01", totalSales: 1500, profit: 12, loss: 500, turnover: 50, deltaSalesPercentage: 0.56, etc },
];

I know that runtime components that have the ability to parse JSON directly (e.g. fetch and XMLHttpRequest) are able to mitigate certain performance harms that come from using object-literal notation (e.g. a the parser inspects the array and sees that all object-literals share the same set of property names and can therefore define an internal hidden base class for them within the runtime) - but that optimization doesn't apply in this case because the data is being rendered into the an SSR web-page and I understand even recent performance benchmarks don't show any parser or compiler optimizations for the "all array elements look-alike" scenario).

Is there a way for Json.NET to do this for me automatically, using reflection to automatically generate a JavaScript constructor and JavaScript constructor calls?

Dai
  • 141,631
  • 28
  • 261
  • 374
  • I've never used it, but given that [`JsonToken`](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_JsonToken.htm) includes entries for `StartConstructor` and `EndConstructor`, I'd say... maybe? Have you tried writing a custom converter? – p.s.w.g Feb 19 '19 at 05:53
  • Can you just produce the values for the constructor as a list? If so, you can just make the constructor accept that list instead of `(parm1, param2, param3)` etc., or call it via `apply(this, parameterArray)`. However, are you sure that the property names are such a big overhead? Trying to omit them seems like premature optimisation - I'd expect gzipping the payload to negate the "problem" with transferring them. – VLAZ Feb 19 '19 at 05:55
  • There is no way to do this automatically, but you can do it with a [custom `JsonConverter`](http://www.newtonsoft.com/json/help/html/CustomJsonConverter.htm) that calls [`JsonWriter.WriteStartConstructor(string name)`](https://www.newtonsoft.com/json/help/html/M_Newtonsoft_Json_JsonWriter_WriteStartConstructor.htm) and later `JsonWriter.WriteEndCOnstructor()`. See [When using a JsonWriter, what's the purpose of WriteStartConstructor?](https://stackoverflow.com/q/31778612/3744182) for details. The custom converter can be made generic by using reflection or the `serializer.ContractResolver`. – dbc Feb 19 '19 at 05:55
  • Your question asks, *Is there a way for Json.NET to do this for me automatically...* Is a generic `JsonConverter` that uses reflection sufficient? – dbc Feb 19 '19 at 06:08

1 Answers1

3

There is no way to do this fully automatically, but you can do it with a custom JsonConverter that calls JsonWriter.WriteStartConstructor(string name) and later JsonWriter.WriteEndCOnstructor(). See When using a JsonWriter, what's the purpose of WriteStartConstructor? for details. The custom converter can be made generic by using .Net reflection or the Json.NET's cached metadata as returned by serializer.ContractResolver, but if it is, it will require some way to determine the constructor argument order.

For instance, say your type WeeklySalesData looks something like the following:

public class WeeklySalesData
{
    string weekName;
    DateTime date;
    decimal totalSales;

    // If WeeklySalesData had multiple constructors, you could mark the one to use as follows:
    // [JsonConstructor]
    public WeeklySalesData(string weekName, DateTime date, decimal totalSales)
    {
        this.weekName = weekName;
        this.date = date;
        this.totalSales = totalSales;
    }

    public string WeekName { get { return weekName; } }

    public DateTime Date { get { return date; } }

    public decimal TotalSales { get { return totalSales; } }
}

Notice it has a parameterized constructor that will be used by Json.NET to construct the type during deserialization. To serialize such a type using constructor format, first introduce the following converter:

public class ConstructorConverter<T> : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return typeof(T) == objectType;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var contract = serializer.ContractResolver.ResolveContract(value.GetType()) as JsonObjectContract;
        if (contract == null)
            throw new JsonSerializationException(string.Format("Type {0} does not correspond to a JSON object.", value.GetType()));
        // Possibly check whether JsonObjectAttribute is applied, and use JsonObjectAttribute.Title if present.
        writer.WriteStartConstructor(value.GetType().Name);
        foreach (var provider in contract.GetConstructorParameterValueProviders())
        {
            serializer.Serialize(writer, provider.GetValue(value));
        }
        writer.WriteEndConstructor();
    }
}

public static partial class JsonExtensions
{
    internal static IEnumerable<IValueProvider> GetConstructorParameterValueProviders(this JsonObjectContract contract)
    {
        return contract.CreatorParameters.Select(p => contract.GetConstructorParameterValueProvider(p)).ToArray();
    }

    internal static IValueProvider GetConstructorParameterValueProvider(this JsonObjectContract contract, JsonProperty parameter)
    {
        if (parameter.ValueProvider != null)
            return parameter.ValueProvider;
        var property = contract.Properties.GetClosestMatchProperty(parameter.PropertyName);
        var provider = property == null ? null : property.ValueProvider;
        if (provider == null)
            throw new JsonSerializationException(string.Format("Cannot get IValueProvider for {0}", parameter));
        return provider;
    }
}

Then serialize using the following converters:

var data = new WeeklySalesData("2018W1", new DateTime(2019, 2, 15, 0, 0, 0, DateTimeKind.Utc), 1100);
var settings = new JsonSerializerSettings
{
    Converters = { new JavaScriptDateTimeConverter(), new ConstructorConverter<WeeklySalesData>() },
};

var json = JsonConvert.SerializeObject(new [] { data }, Formatting.Indented, settings);

Which results in:

[
  new WeeklySalesData(
    "2018W1",
    new Date(
      1550188800000
    ),
    1100.0
  )
]

Notes:

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Wow! Thank you :D (regarding `Date(1233)` values, they aren't real - I copied them from unixtimestamp.com so they're in seconds, not milliseconds). – Dai Feb 19 '19 at 07:37