SMAPI - Stardew Modding API

SMAPI - Stardew Modding API

971k Downloads

Provide reflection helper for mods

Pathoschild opened this issue · 7 comments

commented

Mods often need to access private fields or methods, typically using fragile or unvalidated code. Provide a reflection helper that mods can optionally use instead.

commented

The goal is to have a few simple utility methods relevant to most Stardew Valley mods, not to address every use case. (Mods that need more can use the .NET reflection API directly or use one of the existing reflection libraries.) Here are two possible approaches with that in mind.

Proposal A: simple utility methods

Interface

One option is to create a few simple utility methods which let mods get a private value or method directly:

public interface IReflectionHelper
{
   /// <summary>Get a private field value.</summary>
   /// <typeparam name="TValue">The field type.</typeparam>
   /// <param name="parent">The parent object.</param>
   /// <param name="name">The field name.</param>
   /// <param name="required">Whether to throw an exception if the private field is not found.</param>
   TValue GetPrivateField<TValue>(object obj, string name, bool required = true);

   /// <summary>Set a private field value.</summary>
   /// <typeparam name="TValue">The field type.</typeparam>
   /// <param name="parent">The parent object.</param>
   /// <param name="name">The field name.</param>
   /// <param name="value">The value to set.</param>
   /// <param name="required">Whether to throw an exception if the private field is not found.</param>
   void SetPrivateField<TValue>(object obj, string name, TValue value, bool required = true);

   /// <summary>Get a private method.</summary>
   /// <param name="parent">The parent object.</param>
   /// <param name="name">The field name.</param>
   /// <param name="required">Whether to throw an exception if the private field is not found.</param>
    MethodInfo GetPrivateMethod(object parent, string name, bool required = true);
}

Usage

This would be exposed as a mod property (e.g. this.ReflectionHelper) or helper property (e.g. helper.Reflection). Here's how you would...

  • Get a private field value:
    string value = helper.Reflection.GetPrivateField<string>(obj, "fieldName");
  • Set a private field value:
    helper.Reflection.SetPrivateField<string>(obj, "fieldName", value);
  • Cache a private field for later use: not possible.
  • Call a private method and capture its return value:
    string value = (string)helper.Reflection.GetPrivateMethod(obj, "methodName").Invoke(arguments);

Pros & cons

  • Pros:
    • ✓ Most common cases are very simple.
    • ✓ Validated (e.g. a meaningful error will be thrown if the field or method doesn't exist).
    • ✓ Somewhat flexible (e.g. you can fallback by setting required: false).
    • ✓ Self-contained interface, easy to change the implementation.
  • Cons:
    • ✘ Less common cases aren't supported.
    • ✘ Mods can't cache the reflected fields.
    • ✘ Handling edge cases like static fields would significantly increase the number of methods.
    • ✘ Inconsistent (e.g. returns the value for fields, but a MethodInfo for methods).

Proposal B: wrapped reflection API

Interface

Another option is to provide a thin wrapper around .NET's build-in reflection API for validation and strong typing:

public interface IReflectionHelper
{
   /// <summary>Get a private field value.</summary>
   /// <typeparam name="TValue">The field type.</typeparam>
   /// <param name="parent">The parent object which has the field.</param>
   /// <param name="name">The field name.</param>
   /// <param name="required">Whether to throw an exception if the private field is not found.</param>
   IFieldInfo<TValue> GetPrivateField<T>(object obj, string name, bool required = true);

   /// <summary>Get a private method.</summary>
   /// <param name="parent">The parent object which has the method.</param>
   /// <param name="name">The field name.</param>
   /// <param name="required">Whether to throw an exception if the private field is not found.</param>
   IMethodInfo GetPrivateMethod(object parent, string name, bool required = true);
}

The IFieldInfo<TValue> would look something like this:

public interface IFieldInfo<TValue>
{
   /// <summary>The reflection metadata.</summary>
   FieldInfo FieldInfo { get; }

   /// <summary>Get the field value.</summary>
   TValue GetValue();

   /// <summary>Set the field value.</summary>
   //// <param name="value">The value to set.</param>
   void SetValue(TValue value);
}

And the IMethodInfo would look something like this:

public interface IMethodInfo
{
   /// <summary>The reflection metadata.</summary>
   MethodInfo MethodInfo { get; }

   /// <summary>Invoke the method.</summary>
   /// <typeparam name="TValue">The return type.</typeparam>
   /// <param name="args">The method arguments.</param>
   TValue Invoke<TValue>(params object[] arguments);

   /// <summary>Invoke the method.</summary>
   /// <param name="args">The method arguments.</param>
   void Invoke(params object[] arguments);
}

Usage

This would be exposed as a mod property (e.g. this.ReflectionHelper) or helper property (e.g. helper.Reflection). Here's how you would...

  • Get a private field value:
    string value = helper.Reflection.GetPrivateField<string>(obj, "fieldName").GetValue();
  • Set a private field value:
    helper.Reflection.GetPrivateField<string>(obj, "fieldName").SetValue(value);
  • Cache a private field for later use:
    IFieldInfo<string> field = helper.Reflection.GetPrivateField<string>(obj, "fieldName");
  • Call a private method and capture its return value:
    string value = helper.Reflection.GetPrivateMethod(obj, "methodName").Invoke<string>(arguments);

Pros & cons

  • Pros:
    • ✓ Simple to use.
    • ✓ Validated (e.g. a meaningful error will be thrown if the field or method doesn't exist).
    • ✓ Flexible (you can get the underlying reflection API to do more).
    • ✓ Self-contained interface, easy to change the implementation.
    • ✓ Mods can cache the IFieldInfo<TValue> or underlying FieldValue.
    • ✓ Handling edge cases (like static fields) doesn't significantly increase complexity, since most of the methods are chained.
  • Cons:
    • ✘ The most common cases are slightly more complicated than proposal A.
commented

Proposal B seems like a good approach simply due to the pro/con ratio largely out-weighing A. Having a standard utility for this will definitely be useful as I often have to drag the same reflection utilities between my own mods which can be a pain to maintain. 👍

commented

I hereby suggest proposal C where internally, it works like proposal B, but we wrap the mechanics so that users can use them as in proposal A, I have already setup a similar system myself, that also includes the technical side of making the reflection in both case A and B as efficient as doable: https://github.com/Entoarox/StardewMods/tree/master/Framework/Reflection

commented

@Entoarox we can combine your approach nicely with the above proposals, so mod authors have cached access to both the full reflection API and simplified wrappers:

// use reflection API
FieldInfo field = GetPrivateField<string>(obj, "fieldName").FieldInfo;

// use wrapper
string value = GetPrivateField<string>(obj, "fieldName").GetValue();

// use it later
this.Field = GetPrivateField<string>(obj, "fieldName");
...
this.Field.GetValue();

This has a few advantages over any of the proposals individually:

  • The reflection can be cleanly cached and optimised internally.
  • If you store the field or method, you can use the same APIs on it (so there aren't multiple ways of doing something from the same API).
  • The fluent interface is discoverable; for example, autocompletion after GetPrivateField<T>(…) will only show you field methods, so it's easy to write code quickly without scrolling through methods.
  • The fluent interface is easy to extend. For example, you could add a field.HasValue() by extending IFieldInfo<T>. It would be much messier to add that to proposal A (e.g. you'd have FieldHasValue<T>(obj, "fieldName") mixed in with the other methods, not even counting the static/non-static versions or flag).
  • It's easy to add your own top-level methods the same way.
commented

👍 from me

commented

Done in the develop branch for the upcoming 1.4 release.

Implementation details:

  • The helper supports static and instance reflection with appropriate overloads.
  • The helper searches up the type hierarchy for a match (e.g. to get a private base field without casting to the base class).
  • The helper throws meaningful exceptions if reflection fails. For example:

    The {typeName} object doesn't have a private '{fieldName}' instance field.
    Can't convert the private {typeName}.{fieldName} field from {fieldType} to {valueType}.

  • The reflected fields & methods are cached with a sliding expiry of 5 minutes (to optimise performance without unnecessary memory usage).

Here's what the reflection API looks like in practice:

screenshot of intellisense

See:

commented

Closed as done; we can reopen it if anything comes up.