65.9K
CodeProject 正在变化。 阅读更多。
Home

将您的 BLL 怪物套上链条。

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (6投票s)

2014 年 9 月 9 日

CPOL

8分钟阅读

viewsIcon

16357

downloadIcon

227

本文展示了如何使用 FubuMVC 的行为链模式 (BMVC) 来改进 BLL 的可维护性。

引言

企业应用程序的一种非常流行的架构是三层架构:应用程序层、业务逻辑层 (BLL) 和数据访问层 (DAL)。不知何故,随着时间的推移,业务层变得越来越臃肿,健康状况也随之恶化。也许,我当时的做法是错误的。

设计得非常好的代码,不知何故会变得陈旧,变成一个无头怪物。我遇到过几个这样的怪物,我曾设法使用 FubuMVC行为链 来驯服它们。这个模式是为 Web 应用程序设计的,但我发现它对于将复杂的 BLL 对象分解成易于维护的可爱小绵羊很有用。

天堂海滩服务

我需要一个例子来让这个工作起来。所以,让我们去海滩。西班牙拥有欧洲最美的海滩。让我们构建一个 Web 服务来搜索梦想中的海滩。我希望客户输入一些标准:省份、沙滩类型、是否为天体海滩、冲浪条件、一些天气条件(有些人可能喜欢阳光,有些人喜欢阴凉,冲浪者肯定想要风)。服务将返回匹配的整个列表。

将有两个入口点

  • 最小化。结果仅包含海滩 ID。客户端必须已下载 海滩列表 JSON
  • 详细。结果将包含我拥有的关于海滩的所有信息。

天气报告将从免费在线天气服务(如 OpenWeatherMap)下载。所有依赖项都将是抽象的,并通过构造函数注入。

public IEnumerable<BeachMin> SearchMin(SearchRequest request) {
	var candidates = beachDal.GetBeachesMatching(
		request.Location, 
		request.TypeOfSand, 
		request.Features);

	var beachesWithWeatherReport = candidates
		.Select(x => new {
			Beach = x,
			Weather = weather.Get(x.Locality);
		});

	var filteredBeaches = beachesWithWeatherReport
		.Where(x => x.Weather.Wind == request.Weather.Wind)
		.Where(x => (x.Weather.Sky & request.Weather.Sky) == request.Weather.Sky)
		.Select(x => x.Beach);

	var orderedByPopularity = filteredBeaches
		.OrderBy(x => x.Popularity);

	return orderedByPopularity
		.Select(x => x.TranslateTo<BeachMin>());
}

这非常简单,看起来代码也很好。但是隐藏在这些代码行中的是 单一职责原则 的违背。在这里,我正在从 DAL 和外部服务获取数据,进行过滤、排序,最后转换数据。这段代码有五种变化的原因。今天看起来可能没问题,但随着代码老化,以后问题就会出现。

让我们给它喂点垃圾食品

在实际的生产场景中,此服务将需要一些附加功能。日志记录,以了解发生了什么并获得一些漂亮的图表;缓存,以使其更高效;以及一些调试信息,以帮助我们根除问题。所有这些行为会去哪里?当然是去业务层。没有人喜欢将任何非数据库特定的内容放入 DAL。Web 服务本身无法访问真实发生的情况。所以……其他所有东西都进入 BLL。这看起来可能有点夸张,但相信我……事实并非如此。

public IEnumerable<BeachMin> SearchMin(SearchRequest request) {

	Debug.WriteLine("Entering SearchMin");

	var stopwatch = new Stopwatch();
	stopwatch.Start();	

	Logger.Log("SearchMin.Request", request);	

	Debug.WriteLine("Before calling DAL: {0}", stopwatch.Elapsed);
	var cacheKey = CreateCacheKey(request);
	var candidates = Cache.Contains(cacheKey)
		? Cache.Get(cacheKey)
		: beachDal.GetBeachesMatching(request.Location, request.TypeOfSand, request.Features);
	Cache.Set(cacheKey, candidates);
	Debug.WriteLine("After calling DAL: {0}", stopwatch.Elapsed);

	Logger.Log("SearchMin.Candidates", candidates);	
	
	Debug.WriteLine("Before calling weather service: {0}", stopwatch.Elapsed);
	var beachesWithWeatherReport = candidates
	.Select(x => new {
		Beach = x,
		Weather = weather.Get(x.Locality);
	});
	Debug.WriteLine("After calling weather service: {0}", stopwatch.Elapsed);
	
	Logger.Log("SearchMin.Weather", beachesWithWeatherReport);
	
	Debug.WriteLine("Before filtering: {0}", stopwatch.Elapsed);
	var filteredBeaches = beachesWithWeatherReport
		.Where(x => x.Weather.Wind == request.Weather.Wind)
		.Where(x => (x.Weather.Sky & request.Weather.Sky) == request.Weather.Sky)
		.Select(x => x.Beach);
	Debug.WriteLine("After filtering: {1}", stopwatch.Elapsed);

	Logger.Log("SearchMin.Filtered", filteredBeaches);

	Debug.WriteLine("Before ordering by popularity: {0}", stopwatch.Elapsed);
	var orderedByPopularity = filteredBeaches
		.OrderBy(x => x.Popularity);
	Debug.WriteLine("After ordering by popularity: {0}", stopwatch.Elapsed);

	Debug.WriteLine("Exiting SearchMin");

	return orderedByPopularity;
}

如果你不拥有像上面那样的代码:太棒了!!你很幸运。我已经写了太多看起来像这样的 BLL。现在,问问自己:“天堂海滩服务”究竟与日志记录、缓存和调试有什么关系?答案很简单:一点关系都没有。

通常,这段代码不会有问题。但每个应用程序都需要维护。随着时间的推移,业务需求会发生变化,我需要修改它。然后会发现一个 bug:再次修改。到某个时候,怪物就会醒来,从那时起将不再有好消息。

实际的业务逻辑

让我们看看我实际在做什么

  1. 查找候选海滩。即位于指定省份、具有所需特征和沙滩类型的海滩。
  2. 获取每个候选海滩的天气报告。
  3. 过滤掉不符合所需天气的海滩。
  4. 按受欢迎程度排序。
  5. 将数据转换为预期的输出。

这就像你手动使用地图、电话和一位耐心的操作员来获取天气报告一样。这正是 BLL 应该做的事情,仅此而已。

我将为以上每个步骤实现一个 BLL,它们将只有一个 `Execute` 方法,带有一个参数和一个返回值。每个步骤都将有一个有意义的、揭示意图的名称,它将接收一个同名、以 `Input` 结尾的参数,并返回一个同名、以 `Output` 结尾的类型。约定真棒!!

public class FindCandidates : IFindCandidates
{ 	
	private readonly IBeachesDal beachesDal;
	
	public FindCandidates(IBeachesDal beachesDal)
	{
		this.beachesDal = beachesDal;
	}

	public FindCandidatesOutput Execute(FindCandidatesInput input)
	{
		var beaches = beachesDal.GetCandidateBeaches(
			input.Province, 
			input.TypeOfSand, 
			input.Features);

		return new FindCandidatesOutput
		{
			Beaches = beaches
		};
	}
}

public class GetWeatherReport : IGetWeatherReport
{
	private readonly IWeatherService weather;

	public GetWeatherReport(IWeatherService weather)
	{
		this.weather = weather;
	}

	public GetWeatherReportOutput Execute(GetWeatherReportInput input)
	{
		var beachesWithWeather = input.Beaches
			.Select(NewCandidateBeachWithWeather);

		return new GetWeatherReportOutput
		{
			BeachesWithWeather = beachesWithWeather
		};
	}

	private CandidateBeachWithWeather NewCandidateBeachWithWeather(CandidateBeach x)
	{
		var result = x.TranslateTo<CandidateBeachWithWeather>();
		result.Weather = weather.Get(x.Locality);

		return result;
	}
}

public class FilterByWeather : IFilterByWeather
{
	public FilterByWeatherOutput Execute(FilterByWeatherInput input)
	{
		var filtered = input.BeachesWithWeather
			.Where(x => x.Weather.Sky == input.Sky)
			.Where(x => input.MinTemperature <= x.Weather.Temperature 
				&& x.Weather.Temperature <= input.MaxTemperature)
			.Where(x => input.MinWindSpeed <= x.Weather.WindSpeed 
				&& x.Weather.WindSpeed <= input.MaxWindSpeed);
	  
		return new FilterByWeatherOutput
		{
			Beaches = filtered
		};
	}
}

public class OrderByPopularity : IOrderByPopularity
{
	public OrderByPopularityOutput Execute(OrderByPopularityInput input)
	{
		var orderedByPopularity = input.Beaches.OrderBy(x => x.Popularity);

		return new OrderByPopularityOutput
		{
			Beaches = orderedByPopularity
		};
	}
}

public class TranslateToBeachMin : IConvertToMinResult
{
	public TranslateToBeachMinOutput Execute(TranslateToBeachMinInput input)
	{
		return new TranslateToBeachMinOutput
		{
			Beaches = input.Beaches
				.Select(x => x.TranslateTo<BeachMin>())
		};
	}
}

我知道你在想:我把一个 15 行代码 (LOC) 的程序变成了 100 多行……你是对的。但让我们看看我得到了什么。五个干净的小型 BLL,每个都代表了我们之前单一 BLL 的一部分。它们的依赖关系是抽象的,这使得它们很容易进行彻底的测试。它们易于管理,因为它们很小,所以很容易维护、替换甚至重用。例如,你不需要进行实时的天气搜索来获取海滩列表和天气条件进行过滤,你只需要创建每个步骤的输入,然后就可以了,你可以执行该特定步骤。最后,我添加了一个步骤来将 `CandidateBeach` 转换为 `BeachMin`,这是我原始服务真正需要的响应。我还为每个步骤提取了接口,这有助于抽象和其他一些我以后会做的事情。

将它们链起来

public IEnumerable<BeachMin> SearchMin(SearchRequest request) {
	var candidates = findCandidates.Execute(new FindCandidatesInput {
		Province = request.Province,
		TypeOfSand = request.TypeOfSand,
		Features = request.Features
	});

	var candidatesWithWeather = getWeatherReport.Execute(new GetWeatherReportInput {
		Beaches = candidates.Beaches
	});

	var filtered = filterByWeather.Execute(new FilterByWeatherInput {
		Beaches = candidates.Beaches,
		Sky = request.Sky,
		MinTemperature = request.MinTemperature,

		MaxTemperature = request.MaxTemperature,
		MinWindSpeed = request.MinWindSpeed,

		MaxWindSpeed = request.MaxWindSpeed
	});

	var orderedByPopularity = orderByPopularity.Execute(new OrderByPopularityInput {
		Beaches = filtered.Beaches
	});

	var result = translateToBeachMin.Execute(new TranslateToBeachMinInput {
		Beaches = orderedByPopularity.Beaches
	});

	return result;
}

你看,我又回到了 15 LOC,也许更少。我认为这段代码甚至不需要解释。我将我们的步骤串联成一个**行为链**。从现在开始,我们将步骤称为**行为**。我有点回到了起点,但现在我们的服务依赖于外部的、可扩展的、可重用的和抽象的行为。但是,它仍然需要知道它们所有。这使得添加新行为变得困难。另一个问题是,另一个入口点几乎会有相同的代码。我必须做些什么来改进这两个。

机械化处理

我知道……莎拉·康纳不会同意。我有一个工具,可以接受一些对象并将它们自动链接成一个函数,但在此之前,让我们看看依赖于函数的服务是什么样的。

public class SearchService : Service {
	public SearchService(
		Func<SearchMinRequest, SearchMinResponse> searchMin,
		Func<SearchDetailsRequest, SearchDetailsResponse> searchDetails) {
			this.searchMin = searchMin;
			this.searchDetails = searchDetails;
	}
	
	public object Any(SearchMinRequest request) {
		return searchMin(request);
	}
	
	public object Any(SearchDetailsRequest request) {
		return searchDetails(request);
	}
}

我使用 ServiceStack 作为 Web 框架。基本上,示例中的 `Any` 方法都是 Web 服务入口点。如你所见,它们通过构造函数将实际工作委托给注入的函数。在某个时候,对于 `ServiceStack` 来说,是在应用程序配置中,我需要创建这些函数并将它们注册到 IoC 容器 中。

public override void Configure(Container container) {
	//...
	var searchMin = Chain<SearchMinRequest, SearchMinResponse>(
		findCandidates,
		getWeatherReport,
		filterByWeather,
		orderByPopularity,
		translateToBeachMin);
		
	var searchDetails = Chain<SearchDetailsRequest, SearchDetailsResponse>(
		findCandidates,
		getWeatherReport,
		filterByWeather,
		orderByPopularity,
		addDetails);
	
	container.Register(searchMin);
	container.Register(searchDetails);
	//...
}

private static Func<TInput, TOutput> Chain<TInput, TOutput>(params object[] behaviors) 
	where TInput : new() 
	where TOutput : new() 
{
	return behaviors
		.ExtractBehaviorFunctions()
		.Chain<TInput, TOutput>();
}

这里有几点值得一提

  • 每个行为在某种程度上都依赖于前一个行为,但它实际上并不知道。
  • 链是从函数创建的,这些函数可以是实例方法或 `static` 方法、lambda 表达式,甚至是另一种语言(如 F#)中定义的函数。
  • `ExtracBehaviorFunctions` 方法接受对象并提取它们的 `Execute` 方法,如果不存在则抛出异常。这是我的约定,你可以定义自己的。
  • `Chain` 方法接受委托并创建一个链接它们的函数。如果使用不兼容的委托,它将抛出异常。

新增功能

我将通过透明的 装饰器 来丰富我们的 BLL。使用 Castle.DynamicProxy,我将生成拦截对我们行为的调用并添加一些功能的类型。然后,我将注册被装饰的实例而不是原始实例。我将从缓存和调试开始。缓存是简单的内存缓存,10 分钟。可以轻松实现更复杂的解决方案。

	container.Register(new ProxyGenerator());
	
	container.Register<ICache>(new InMemory10MinCache());
	
	container.RegisterAutoWired<CacheDecoratorGenerator>();
	CacheDecoratorGenerator = container.Resolve<CacheDecoratorGenerator>();
	
	container.RegisterAutoWired<DebugDecoratorGenerator>();
	DebugDecoratorGenerator = container.Resolve<DebugDecoratorGenerator>();

有了这段代码,我们的装饰器生成器就准备好了,现在让我们看看如何装饰行为。

	var findCandidates = DebugDecoratorGenerator.Generate(
		CacheDecoratorGenerator.Generate(
			container.Resolve<IFindCandidates>()));
			
	var getWeatherReport = DebugDecoratorGenerator.Generate(
			container.Resolve<IGetWeatherReport>());
			
	var filterByWeather = DebugDecoratorGenerator.Generate(
			container.Resolve<IFilterByWeather>());
			
	var orderByPopularity = DebugDecoratorGenerator.Generate(
		container.Resolve<IOrderByPopularity>());
		
	var convertToMinResult = DebugDecoratorGenerator.Generate(
		container.Resolve<IConvertToMinResult>());

在这里,我用调试功能装饰了每个行为,并且只用缓存装饰了 `findCandidates`。为天气报告添加缓存可能很有趣,但由于输入可能是一个非常大的海滩列表,因此缓存可能不正确。相反,我将为 DAL 和天气服务都添加缓存。

	container.Register(c => DebugDecoratorGenerator.Generate(
		CacheDecoratorGenerator.Generate(
			(IBeachesDal) new BeachesDal(c.Resolve<Func<IDbConnection>>()))));

	container.Register(c => DebugDecoratorGenerator.Generate(
		CacheDecoratorGenerator.Generate(
			(IWeatherService) new WeatherService())));

手动装饰器

生成的装饰器对某些任务来说还不够,如果你是 IDE 调试的朋友,它们肯定会让你头疼。总是有手动选择。

public class FindCandidatesLogDecorator : IFindCandidates
{
	private readonly ILog log;
	private readonly IFindCandidates inner;

	public FindCandidatesLogDecorator(ILog log, IFindCandidates inner)
	{
		this.log = log;
		this.inner = inner;
	}

	public FindCandidatesOutput Execute(FindCandidatesInput input)
	{
		var result = inner.Execute(input);
		log.InfoFormat("Execute({0}) returned {1}", input.ToJson(), result.ToJson());

		return result;
	}
}

通过使用更强大的 IoC 容器,如 AutoFac,你将能够创建更强大的装饰器,包括自动生成和手动生成的。除非业务需求发生变化或出现 bug,否则你永远不必修改你的 BLL。

何时使用

当你的 BLL 是一系列步骤时

  • 定义明确。职责清晰且界限分明。
  • 独立。步骤之间互不了解。
  • 顺序执行。顺序不能根据输入改变。所有步骤都必须始终执行。

行为链函数有点像 `static`,它们不打算在执行中被修改。但是,你可以基于任何特定问题的逻辑创建一个新函数来替换现有函数。

工作原理

生成代码并不是那么有趣。只是大量的冗长语句使用优秀的 `Linq.Expressions` 生成 lambda 表达式。你仍然可以在源代码中查看它。让我们来看看生成的代码是如何工作的,或者说大致是这样的。

	var generatedFunction = new Func<SearchDetailsRequest, SearchDetailsResponse>(req => {
		// Input
		var valuesSoFar = new Dictionary<string, object>();
		valuesSoFar["Provice"] = req.Provice;
		valuesSoFar["TypeOfSand"] = req.TypeOfSand;
		valuesSoFar["Features"] = req.Features;
		valuesSoFar["Sky"] = req.Sky;
		valuesSoFar["MinTemperature"] = req.MinTemperature;
		valuesSoFar["MaxTemperature"] = req.MaxTemperature;
		valuesSoFar["MinWindSpeed"] = req.MinWindSpeed;
		valuesSoFar["MaxWindSpeed"] = req.MaxWindSpeed;
		
		// Behavior0: Find candidates
		var input0 = new FindCandidatesInput {
			Provice = (string)valuesSoFar["Provice"],
			TypeOfSand = (TypeOfSand)valuesSoFar["TypeOfSand"],
			Features = (Features)valuesSoFar["Features"]		
		};
		var output0 = behavior0(input0);
		valuesSoFar["Beaches"] = output0.Beaches;
		
		// Behavior1: Get weather report
		var input1 = new GetWeatherReportInput {
			Beaches = (IEnumerable<CandidateBeach>)valuesSoFar["Beaches"]
		}
		var output1 = behavior1(input1);
		valuesSoFar["Beaches"] = output1.Beaches;
		
		// Behavior2: Filter by weather
		var input2 = new FilterByWeather {
			Beaches = (IEnumerable<CandidateBeachWithWeather>)valuesSoFar["Beaches"]
			Sky = (Sky)valuesSoFar["Sky"],
			MinTemperature = (float)valuesSoFar["MinTemperature"],
			MaxTemperature = (float)valuesSoFar["MaxTemperature"],
			MinWindSpeed = (float)valuesSoFar["MinWindSpeed"],
			MaxWindSpeed = (float)valuesSoFar["MaxWindSpeed"]		
		}
		var output2 = behavior2(input2);
		valuesSoFar["Beaches"] = output2.Beaches;
		
		// Behavior3: Order by popularity
		var input3 = new OrderByPopularityInput {
			Beaches = (IEnumerable<CandidateBeach>)valuesSoFar["Beaches"]
		}
		var output3 = behavior3(input3);
		valuesSoFar["Beaches"] = output3.Beaches;
				
		// Behavior4: addDetails
		var input4 = new AddDetailsInput {
			Beaches = (IEnumerable<CandidateBeach>)valuesSoFar["Beaches"]
		}
		var output4 = behavior4(input4);
		valuesSoFar["Beaches"] = output4.Beaches;
		
		// Output
		return new SearchDetailsResponse {
			 Beaches = (IEnumerable<BeachDetails>)valuesSoFar["Beaches"]
		};
	});

Using the Code

如果看不到它工作,它有什么用?

这将以配置的端口(默认是 `52451`)启动服务器。现在你需要创建一个客户端程序。你可以通过使用 ServiceStack 来手动创建一个客户端项目。或者任何其他 Web 框架。你也可以使用包含的 Linqpad 文件,位于 `<project_root>\linqpad\search_for_beaches.linq`,它基本上做了如下事情:

	var client = new JsonServiceClient("https://:52451/");
	var response = client.Post(new SearchDetailedRequest {
		Province = "Huelva",
		Features = Features.Surf,
		MinTemperature = 0f,
		MaxTemperature = 90f,
		MinWindSpeed = 0f,
		MaxWindSpeed = 90f,
		TypeOfSand = TypeOfSand.White,
		Sky = Sky.Clear
	});

结论

高质量的代码对于拥有一个具有长久生命周期的可维护应用程序至关重要。通过选择合适的设计模式并应用一些技术和最佳实践,任何工具都可以为我们服务,并为我们的问题产生真正优雅的解决方案。另一方面,如果你只学会如何使用工具,你最终会为工具编程,而不是为你支付薪水的人编程。

© . All rights reserved.