This article explains how the calls to the synchronous methods of WeatherClient reach one run-time object,
CallLog. CallLog writes the method, the arguments and the result of each call. It is plain C# code, and it does
not depend on the signatures of the methods of WeatherClient.
The Describe method of WeatherReport calls a void method, SetUnit, and a method that returns a value,
GetTemperature:
12 // Synchronous methods, with and without a return value.
13 public string Describe( string city, TemperatureUnit unit = TemperatureUnit.Celsius )
14 {
15 try
16 {
17 this._client.SetUnit( unit );
18 var temperature = this._client.GetTemperature( city );
19
20 return $"{city}: {temperature} {unit}";
21 }
22 catch ( ArgumentException )
23 {
24 return $"{city}: unknown city";
25 }
26 }
27Metalama rewrites both calls so that they call generated interceptor methods. The following test shows the transformation:
1using Metalama.Samples.UniversalInterceptor.Weather;
2
3namespace Metalama.Samples.UniversalInterceptor.Tests.SynchronousCalls;
4
5// The calls to a method with a return value and to a void method are redirected to CallLog.Current.
6public class WeatherReport
7{
8 private readonly WeatherClient _client = new();
9
10 public double GetTemperature( string city, TemperatureUnit unit )
11 {
12 this._client.SetUnit( unit );
13
14 return this._client.GetTemperature( city );
15 }
16}
17
1using Metalama.Samples.UniversalInterceptor.Weather;
2
3namespace Metalama.Samples.UniversalInterceptor.Tests.SynchronousCalls;
4
5// The calls to a method with a return value and to a void method are redirected to CallLog.Current.
6public class WeatherReport
7{
8 private readonly WeatherClient _client = new();
9
10 public double GetTemperature(string city, TemperatureUnit unit)
11 {
12 MetalamaInterceptors.WeatherClient_SetUnit_Interceptor(this._client, unit: unit);
13
14 return MetalamaInterceptors.WeatherClient_GetTemperature_Interceptor(this._client, city: city);
15 }
16}
17
When the program calls Describe, CallLog writes the following lines:
[call] WeatherClient.SetUnit(Celsius) succeeded.
[call] WeatherClient.GetTemperature(Prague) returned 12.5.
Prague: 12.5 Celsius
[call] WeatherClient.SetUnit(Fahrenheit) succeeded.
[call] WeatherClient.GetTemperature(Lisbon) returned 69.8.
Lisbon: 69.8 Fahrenheit
[call] WeatherClient.SetUnit(Celsius) succeeded.
[call] WeatherClient.GetTemperature(Atlantis) threw ArgumentException: Unknown city 'Atlantis'. (Parameter 'city')
Atlantis: unknown city
The run-time interface
The run-time object implements the IUniversalInterceptor interface. Its design follows the IInterceptor interface
of the static proxy example. A synchronous call goes to the Invoke method:
10 // Intercepts a call to a synchronous method. The ref and out arguments are written back to the call site from args
11 // after the call. For a void method, TResult is ValueTuple.
12 TResult Invoke<TArgs, TResult>(
13 ref TArgs args,
14 InterceptionMetadata metadata,
15 InterceptorDelegate<TArgs, TResult> proceed )
16 where TArgs : struct, ITuple;
17TArgs is a value tuple that contains the receiver of the call, when the intercepted method is not static, and the
arguments. For a call to client.GetTemperature( "Prague" ), TArgs is (WeatherClient receiver, string city).
The constraint ITuple lets the run-time code read the elements of any tuple by index.
A void method has no result, so the generated code uses ValueTuple as TResult for it. One Invoke method thus
handles the methods with and without a return value.
The delegate calls the intercepted method with the elements of the tuple. It receives the tuple by reference, so that
the intercepted method can write its ref and out arguments to the tuple. See
Universal interceptor example, step 3: Handling ref and out parameters.
1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.UniversalInterceptor;
4
5// Calls the intercepted method with the receiver and the arguments read from args. The method writes its ref and out
6// arguments to args.
7public delegate TResult InterceptorDelegate<TArgs, out TResult>( ref TArgs args )
8 where TArgs : struct, ITuple;
9
InterceptionMetadata describes the intercepted method:
1namespace Metalama.Samples.UniversalInterceptor;
2
3// Describes the intercepted method. It is a struct, so the generated code creates it at each call without allocating
4// memory on the heap.
5public readonly struct InterceptionMetadata
6{
7 public InterceptionMetadata( Type declaringType, string methodName, string signature, bool hasReceiver )
8 {
9 this.DeclaringType = declaringType;
10 this.MethodName = methodName;
11 this.Signature = signature;
12 this.HasReceiver = hasReceiver;
13 }
14
15 // The type that declares the intercepted method.
16 public Type DeclaringType { get; }
17
18 // The name of the intercepted method.
19 public string MethodName { get; }
20
21 // The name of the intercepted method with the types of its parameters, for instance
22 // "WeatherClient.GetTemperature(string)".
23 public string Signature { get; }
24
25 // Indicates whether the first element of the argument tuple is the receiver of the call, which is the object on
26 // which the method is called.
27 public bool HasReceiver { get; }
28
29 public override string ToString() => this.Signature;
30}
31
The logger
CallLog implements IUniversalInterceptor. Its Invoke method writes the call, calls proceed, and writes the
result or the exception. It reads the arguments from the tuple through ITuple:
13 public TResult Invoke<TArgs, TResult>(
14 ref TArgs args,
15 InterceptionMetadata metadata,
16 InterceptorDelegate<TArgs, TResult> proceed )
17 where TArgs : struct, ITuple
18 {
19 var arguments = FormatArguments( args, metadata );
20 var call = $"{metadata.DeclaringType.Name}.{metadata.MethodName}({arguments})";
21
22 try
23 {
24 // The intercepted method writes its ref and out arguments to args.
25 var result = proceed( ref args );
26
27 var argumentsAfterCall = FormatArguments( args, metadata );
28
29 Console.WriteLine(
30 argumentsAfterCall == arguments
31 ? $"[call] {call} {FormatResult( result )}."
32 : $"[call] {call} {FormatResult( result )}, and changed the arguments to ({argumentsAfterCall})." );
33
34 return result;
35 }
36 catch ( Exception e )
37 {
38 Console.WriteLine( $"[call] {call} threw {e.GetType().Name}: {e.Message}" );
39
40 throw;
41 }
42 }
43100 // Formats the elements of the tuple, except the receiver.
101 private static string FormatArguments<TArgs>( TArgs args, InterceptionMetadata metadata )
102 where TArgs : struct, ITuple
103 {
104 var arguments = new List<string>();
105
106 for ( var i = metadata.HasReceiver ? 1 : 0; i < args.Length; i++ )
107 {
108 arguments.Add( Format( args[i] ) );
109 }
110
111 return string.Join( ", ", arguments );
112 }
113The fabric
The following project fabric redirects every call to the methods of WeatherClient to the interceptor that the
CallLog.Current member gives:
1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Fabrics;
3using Metalama.Samples.UniversalInterceptor.Weather;
4
5namespace Metalama.Samples.UniversalInterceptor;
6
7// Redirects every call to the types of the weather library, in the whole project, to the interceptor of the current
8// asynchronous flow. CallLog.Current is an AsyncLocal<IUniversalInterceptor>, so the generated code reads its Value.
9internal class InterceptionFabric : ProjectFabric
10{
11 public override void AmendProject( IProjectAmender amender )
12 {
13 foreach ( var type in new[] { typeof(WeatherClient), typeof(RequestQuota) } )
14 {
15 amender.InterceptMethods(
16 calls => calls
17 .Type( type )
18 .AllMethods()
19 .ForEachSite(
20 site =>
21
22 // The calls on a struct receiver are not intercepted, because the tuple would contain a
23 // copy of the receiver.
24 !site.InterceptedMethod.IsStatic && site.Receiver.Type is { IsReferenceType: false }
25 ? site.Skip()
26 : site.RedirectToUniversalInterceptor( typeof(CallLog), nameof(CallLog.Current) ) ) );
27 }
28 }
29}
30
RedirectToUniversalInterceptor is a compile-time extension method of IMethodResultFactory. It is defined in this
project. Its first argument is a type that implements IUniversalInterceptor, and the second argument is the name of
a static field or property of this type, which gives the instance. The ForEachSite call, the RequestQuota type and
the type of CallLog.Current are explained in Universal interceptor example, step 4: Putting it all together.
Generating the interceptors
For each call site, RedirectToUniversalInterceptor chooses a T# template from the return type of the intercepted
method. A synchronous method gets the Invoke template, or the InvokeVoid template when it returns void:
74// Chooses the template of a synchronous method. A void method gets the InvokeVoid template, and has no result type.
75private static void ChooseSynchronousTemplate( IMethod method, out string template, out IType? resultType )
76{
77 if ( method.ReturnType.Equals( SpecialType.Void ) )
78 {
79 template = nameof(UniversalInterceptorTemplates.InvokeVoid);
80 resultType = null;
81 }
82 else
83 {
84 template = nameof(UniversalInterceptorTemplates.Invoke);
85 resultType = method.ReturnType;
86 }
87}
88Then it calls RedirectToSynthesizedMethod with the template. The template receives the type of the tuple and the
result type as compile-time type parameters:
143// Redirects the call to a method generated from the template. The template receives the type of the argument tuple,
144// the result type, if any, and the member that gives the interceptor.
145private static IMethodInterception Redirect(
146 IMethodInterceptionSite site,
147 string template,
148 IType? resultType,
149 Type interceptorType,
150 string memberName,
151 bool isAsyncLocal )
152{
153 var argsType = CreateArgsType( site );
154
155 // LAMA0872 is reported because the analyzer cannot verify that a TemplateProvider or an anonymous type is
156 // durable. This result is created in the delegate of ForEachSite, for one call site of the current compilation.
157#pragma warning disable LAMA0872
158 var interception = site
159 .RedirectToSynthesizedMethod( template )
160 .WithTemplateProvider( TemplateProvider.FromType<UniversalInterceptorTemplates>() );
161
162 return resultType == null
163 ? interception.WithArgs( new { TArgs = argsType, interceptorType, memberName, isAsyncLocal } )
164 : interception.WithArgs(
165 new { TArgs = argsType, TResult = resultType, interceptorType, memberName, isAsyncLocal } );
166#pragma warning restore LAMA0872
167}
168The type of the tuple is created with TypeFactory.CreateTupleType. Its elements have the names of the parameters of
the generated method: receiver, then the parameters of the intercepted method. For a call to an extension method in
reduced form, such as text.Wrap( ">" ), the receiver is the first parameter of the static method:
228// Creates the type of the argument tuple: the receiver, if any, then the arguments. The elements have the names of
229// the parameters of the generated interceptor, so the templates create the same type from these parameters.
230private static ITupleType CreateArgsType( IMethodInterceptionSite site )
231{
232 var method = site.InterceptedMethod;
233
234 // For a call to an extension method in reduced form, such as text.Wrap( ">" ), the intercepted method is the
235 // static method, and its first parameter receives the receiver of the call.
236 var isReducedExtension = method.Parameters.Count > 0
237 && method.Parameters[0].IsThis
238 && site.Receiver.Kind != InvocationReceiverKind.None;
239
240 var elements = new List<(IType Type, string Name)>();
241
242 if ( !method.IsStatic || isReducedExtension )
243 {
244 // The generated interceptor names its receiver parameter "receiver". When a parameter of the intercepted
245 // method already has this name, the name gets leading underscores.
246 var receiverName = "receiver";
247
248 while ( method.Parameters.Any( p => p.Name == receiverName ) )
249 {
250 receiverName = "_" + receiverName;
251 }
252
253 elements.Add( (isReducedExtension ? method.Parameters[0].Type : method.DeclaringType, receiverName) );
254 }
255
256 elements.AddRange( method.Parameters.Skip( isReducedExtension ? 1 : 0 ).Select( p => (p.Type, p.Name) ) );
257
258 return TypeFactory.CreateTupleType( elements );
259}
260The Invoke template creates the tuple, the metadata and the interceptor, and passes the tuple to Invoke with a local
function that calls the intercepted method. The branch for ref and out parameters is explained in
Universal interceptor example, step 3: Handling ref and out parameters.
16// For a synchronous method that returns a value.
17[Template]
18public static TResult Invoke<[CompileTime] TArgs, [CompileTime] TResult>(
19 [CompileTime] Type interceptorType,
20 [CompileTime] string memberName,
21 [CompileTime] bool isAsyncLocal )
22 where TArgs : struct, ITuple
23{
24 var argsType = TypeFactory.CreateTupleType( meta.Target.Method.Parameters );
25 var args = (TArgs) CreateArgs( argsType ).Value!;
26 var metadata = (InterceptionMetadata) CreateMetadata().Value!;
27 IUniversalInterceptor interceptor = GetInterceptor( interceptorType, memberName, isAsyncLocal ).Value!;
28
29 if ( !HasWritableParameters() )
30 {
31 return interceptor.Invoke( ref args, metadata, Proceed );
32 }
33
34 var argsExpression = ExpressionFactory.Capture( args );
35
36 try
37 {
38 return interceptor.Invoke( ref args, metadata, Proceed );
39 }
40 finally
41 {
42 WriteBackArguments( argsType, argsExpression );
43 }
44
45 static TResult Proceed( ref TArgs receivedArgs )
46 {
47 return GetInvoker( "receivedArgs", out var arguments )
48 .Invoke( arguments )!;
49 }
50}
51The InvokeVoid template does the same for a void method. Its local function returns default( ValueTuple ):
52// For a synchronous method that returns void. The result of the interceptor is a ValueTuple, which is ignored.
53[Template]
54public static void InvokeVoid<[CompileTime] TArgs>(
55 [CompileTime] Type interceptorType,
56 [CompileTime] string memberName,
57 [CompileTime] bool isAsyncLocal )
58 where TArgs : struct, ITuple
59{
60 var argsType = TypeFactory.CreateTupleType( meta.Target.Method.Parameters );
61 var args = (TArgs) CreateArgs( argsType ).Value!;
62 var metadata = (InterceptionMetadata) CreateMetadata().Value!;
63 IUniversalInterceptor interceptor = GetInterceptor( interceptorType, memberName, isAsyncLocal ).Value!;
64
65 if ( !HasWritableParameters() )
66 {
67 interceptor.Invoke( ref args, metadata, Proceed );
68 }
69 else
70 {
71 var argsExpression = ExpressionFactory.Capture( args );
72
73 try
74 {
75 interceptor.Invoke( ref args, metadata, Proceed );
76 }
77 finally
78 {
79 WriteBackArguments( argsType, argsExpression );
80 }
81 }
82
83 static ValueTuple Proceed( ref TArgs receivedArgs )
84 {
85 GetInvoker( "receivedArgs", out var arguments ).Invoke( arguments );
86
87 return default;
88 }
89}
90The templates call compile-time helper methods. CreateArgs creates the tuple from the parameters of the generated
method, CreateMetadata creates the InterceptionMetadata value, and GetInvoker returns the intercepted method,
bound to the receiver that it reads from the tuple:
183// Creates the expression of the argument tuple from the parameters of the generated interceptor. An out parameter
184// has no value before the call, so its element is the default value.
185[CompileTime]
186private static IExpression CreateArgs( ITupleType argsType )
187 => argsType.CreateCreateInstanceExpression(
188 meta.Target.Method.Parameters.Select(
189 p => p.RefKind == RefKind.Out ? ExpressionFactory.Default( p.Type ) : (IExpression) p ) );
190191// Creates the expression that creates the InterceptionMetadata of the intercepted method.
192[CompileTime]
193private static IExpression CreateMetadata()
194{
195 var method = meta.Interception.Method.InterceptedMethod;
196
197 var builder = new ExpressionBuilder();
198 builder.AppendVerbatim( "new " );
199 builder.AppendTypeName( typeof(InterceptionMetadata) );
200 builder.AppendVerbatim( "(" );
201 builder.AppendExpression( method.DeclaringType.ToTypeOfExpression() );
202 builder.AppendVerbatim( ", " );
203 builder.AppendLiteral( method.Name );
204 builder.AppendVerbatim( ", " );
205 builder.AppendLiteral( method.ToDisplayString() );
206 builder.AppendVerbatim( ", " );
207 builder.AppendVerbatim( meta.Interception.Method.ReceiverParameter != null ? "true" : "false" );
208 builder.AppendVerbatim( ")" );
209
210 return builder.ToExpression();
211}
212246// Returns the invoker of the intercepted method, and the arguments read from the tuple, which is the parameter of
247// the local function named tupleParameterName. For an instance call, the invoker calls the method on the receiver
248// read from the tuple.
249[CompileTime]
250private static IMethodInvoker GetInvoker( string tupleParameterName, out IEnumerable<IExpression> arguments )
251{
252 var interception = meta.Interception.Method;
253 var argsType = TypeFactory.CreateTupleType( meta.Target.Method.Parameters );
254
255 // The expression must have the tuple type and be referenceable, so that its elements can be passed by ref or out.
256 var tuple = ExpressionFactory.Parse( tupleParameterName, argsType, isReferenceable: true )!;
257
258 var receiver = interception.ReceiverParameter == null
259 ? null
260 : argsType.TupleElements[interception.ReceiverParameter.Index].WithObject( tuple );
261
262 var argumentList = interception.ArgumentParameters
263 .Select( p => argsType.TupleElements[p.Index].WithObject( tuple ) )
264 .ToList();
265
266 // The intercepted method of a call to an extension method in reduced form is the static method. The receiver
267 // is its first argument.
268 if ( receiver != null && interception.InterceptedMethod.Parameters is [{ IsThis: true }, ..] )
269 {
270 argumentList.Insert( 0, receiver );
271 arguments = argumentList;
272
273 return interception.InterceptedMethod;
274 }
275
276 arguments = argumentList;
277
278 return receiver == null ? interception.InterceptedMethod : interception.InterceptedMethod.WithObject( receiver );
279}The templates generate the following interceptors for GetTemperature and SetUnit:
internal static double WeatherClient_GetTemperature_Interceptor(WeatherClient receiver, string city)
{
var args = (receiver, city);
var metadata = new InterceptionMetadata(typeof(WeatherClient), "GetTemperature", "WeatherClient.GetTemperature(string)", true);
IUniversalInterceptor interceptor = CallLog.Current.Value;
return (double)interceptor.Invoke(ref args, metadata, Proceed);
static double Proceed(ref (WeatherClient receiver, string city) receivedArgs)
{
return receivedArgs.receiver.GetTemperature(receivedArgs.city);
}
}
internal static void WeatherClient_SetUnit_Interceptor(WeatherClient receiver, TemperatureUnit unit)
{
var args = (receiver, unit);
var metadata = new InterceptionMetadata(typeof(WeatherClient), "SetUnit", "WeatherClient.SetUnit(TemperatureUnit)", true);
IUniversalInterceptor interceptor = CallLog.Current.Value;
interceptor.Invoke(ref args, metadata, Proceed);
static ValueTuple Proceed(ref (WeatherClient receiver, TemperatureUnit unit) receivedArgs)
{
receivedArgs.receiver.SetUnit(receivedArgs.unit);
return default;
}
}
The local function Proceed is static: it reads the receiver and the arguments from the tuple, not from the
parameters of the generated method. The C# compiler therefore caches the delegate, and the call does not allocate
it.