這個做法介紹該如何使用 Expression API 以及 Expression Trees 的相關概念。

在 C# 舊版本中想要處理動態相關或者在執行期別產生程式碼等功能需要透過反射,但是現在的 C# 我們有更好的選擇那就是 ExpressionExpression Trees

在寫一個 Expression 時,它的語法看起來與一般的 C# 程式碼相同,例如下面這段程式碼寫了一個 lambda 表達式,但是實際上它已經轉換成 Expression Tree 了, 目前它代表的意義是紀錄右側程式碼的結構,所以我們已經不能把它當成委派來呼叫了。

Expression<Func<int, int>> square = x => x * x;
// square(5);

使用 Expression 最常見的做法就是呼叫 Compile() 方法將這段結構編譯成一個實際可運作的委派。

Func<int, int> func = square.Compile();
Console.WriteLine(func(5)); // 輸出 25

我們可以透過這個機制來產生 client-side proxy,正常都需要額外依賴外部工具來產生 client-side proxy,但是只要我們的後端服務介面有修改 或改變參數的數量就必須要更新 proxy,這時候就能嘗試使用 Expression 來解決這個問題。

這裡我們建立一個通用的代理類別 ClientProxy,可以透過它動態解析語法並執行方法呼叫。

void Main()
{
	var client = new ClientProxy<IService>();
	var result = client.CallInterface<string>(srv => srv.DoWork(172));
}
public interface IService
{
	string DoWork(int value);
}

可以將傳入的 lambda srv => srv.DoWork(172) 轉型成 Expression 並進行分析,這也是 Expression 常用功能之一,所以現在我們得知 目前要呼叫的是 IService.DoWork 方法,並且能解析出要傳入的引數為 Int32 172,有了這兩個資訊未來就可以直接呼叫背後實做的內容,

public class ClientProxy<T>
{
	public TResult CallInterface<TResult>(Expression<Func<T, TResult>> op)
	{
		var exp = op.Body as MethodCallExpression;
		var methodName = exp.Method.Name;
		var allParameters = from element in exp.Arguments
							select processArgument(element);
		Console.WriteLine($"Calling {methodName}");
		foreach (var parm in allParameters)
			Console.WriteLine(@$"Parameter type = {parm.ParmType}, Value = {parm.ParmValue}");
		return default(TResult);
	}

	private (Type ParmType, object ParmValue) processArgument(Expression element)
	{
		object argument = default(object);
		LambdaExpression expression = Expression.Lambda(Expression.Convert(element, element.Type));
		Type parmType = expression.ReturnType;
		argument = expression.Compile().DynamicInvoke();
		return (parmType, argument);
	}
}

這個技巧甚至可以在一個 CallInterface 包含另一個 CallInterface,這樣就能讓使用者在執行時期運行想要的程式碼。

client.CallInterface(srver => srver.DoWork(
	client.CallInterface(srv => srv.GetANumber())));

另一個常見的應用是轉型,因為通常第三方的類別會與我方的類別有些微差異,當然你也可以手動進行對應或者是使用類似 automapper 之類的函式庫來處理, 但其實也可以透過 Expression 來處理這個問題。

下面這段程式碼目標是把 SourceContact 轉換成第三方的格式 DestinationContact,接下來會建立一個轉換的泛型方法 Converter

void Main()
{
	var source = new SourceContact { Name = "Alice", Email = "alice@example.com" };
	var converter = new Converter<SourceContact, DestinationContact>();
	var dest = converter.ConvertFrom(source);

	Console.WriteLine($"MyName: {dest.MyName}, Email: {dest.MyEmail}");
}

public class SourceContact
{
	public string Name { get; set; }
	public string Email { get; set; }
}

public class DestinationContact
{
	[DisplayName("Name")]
	public string MyName { get; set; }
	[DisplayName("Email")]
	public string MyEmail { get; set; }
}

這個方法的關鍵就是用源頭類別的屬性找出對應類別的屬性,這裡可以透過 attribute 將兩邊不同名稱的屬性對應起來,找出屬性後呼叫 Expression.Assign 方法,它背後邏輯就是跟自行 Assign 的步驟相同,接下來使用 Expression.Block 方法,可以把它想像成一般方法中大括號 { ... } 區塊, 要注意 body 中最後一個 Expression 類型會作為這個 block 的回傳類型。

最後再透過 Expression.Lambda 將所有的步驟都組裝起來在 Compile 就能得到最終的委派, 這個委派就是包含所有 Assign 的步驟的一個操作物件。

public class Converter<TSource, TDest>
{
	private Func<TSource, TDest> _converter;

	public TDest ConvertFrom(TSource source)
	{
		if (_converter == null)
		{
			CreateConverterIfNeeded();
		}
		return _converter(source);
	}

	private void CreateConverterIfNeeded()
	{
		var sourceParam = Expression.Parameter(typeof(TSource), "source");
		var destVar = Expression.Variable(typeof(TDest), "dest");

		// 建立屬性對應賦值邏輯
		var assignments = new List<Expression>();
		foreach (var srcProp in typeof(TSource).GetProperties(BindingFlags.Public | BindingFlags.Instance))
		{
			if (!srcProp.CanRead) continue;

			PropertyInfo destProp = null;

			foreach (var prop in typeof(TDest).GetProperties(BindingFlags.Public | BindingFlags.Instance))
			{
				var attr = prop?.GetCustomAttributes(typeof(DisplayNameAttribute), false)
				.Cast<DisplayNameAttribute>()
				.Where(a => a.DisplayName == srcProp.Name)
				.FirstOrDefault();
				
				if (attr != null)
				{
					destProp = prop;
					break;
				}
			}

			if (destProp == null || !destProp.CanWrite) continue;

			// dest.Prop = source.Prop
			var assign = Expression.Assign(
				Expression.Property(destVar, destProp),
				Expression.Property(sourceParam, srcProp)
			);
			assignments.Add(assign);
		}
		var body = new List<Expression>
		{
			Expression.Assign(destVar, Expression.New(typeof(TDest)))
	    };
		body.AddRange(assignments);
		body.Add(destVar); // 作為 return 類型

		var block = Expression.Block(new[] { destVar }, body);
		var lambda = Expression.Lambda<Func<TSource, TDest>>(block, sourceParam);

		_converter = lambda.Compile();
	}
}

上面這個方法產生的 Expression Trees 可以分解成幾個步驟

  1. var result = new DestinationContact()
  2. dest.MyName = source.Name
  3. dest.MyEmail = source.Email
  4. return result

基本上把產生的程式碼想像成下面這樣。

public class Converter<TSource, TDest>  
	where TSource : SourceContact
	where TDest : DestinationContact, new()

{
	private Func<TSource, TDest> _converter = (TSource) =>
	{
		var d = new TDest();
		d.MyName = TSource.Name;
		d.MyEmail = TSource.Email;
		return d;
	};

	public TDest ConvertFrom(TSource source)
	{
		return _converter(source);
	}
}

Summary

這個做法演示了兩種常見的 Expression 應用方式,主要的目的就是在執行時期產生可運作的程式碼, 我們現在可以多使用 Expression 來取代舊有的反射程式碼。