尝试:创建代码试探器






4.88/5 (4投票s)
使代码更能抵抗外部约束,方法是尝试操作。
引言
许多软件开发人员都在寻找方法,使依赖于外部资源和服务的代码更具弹性,这些外部资源和服务可能因网络故障和其他不可预见的情况而不可用或缺失,从而使他们的应用程序具有更高的可用性并减少用户沮丧感。
有时,作为开发人员,您可能需要您的代码在资源或服务缺失时抛出异常,并且这样做是正确的做法。但是,根据异常的不同,您可能希望尝试某个特定的操作,直到成功或满足特定异常、条件或其他标准;也许是因为资源或服务可能会很快恢复,您的应用程序无需遭遇可怕的失败,并给用户和您作为开发人员带来麻烦。
这类操作通常是**一次性**的。通常,尝试逻辑会封装潜在的错误代码,然后就结束了。这绝对没有任何问题,除非您需要再次使用这段代码,那么您必须一遍又一遍地编写它。
本系列文章通过演示如何实现和使用一个**尝试器(Trier)**来解决这个问题:尝试器是一种允许特定指令或代码片段被尝试执行直到达到特定结果(无论是成功还是最终失败)的类。在第一部分中,我们将开发尝试器,您将看到它如何用于提供各种标准用途,还可以扩展它以将非常自定义和特定的尝试逻辑包含到任何代码中,并涵盖可能由于资源或服务不可用而出现的各种应用程序需求和情况。
您还会注意到这个概念与 try/catch/finally 结构非常相似。它类似,但服务于修改后的目的,因为代码片段可能会被执行多次以达到所需的结果,而 try/catch/finally 只执行一次(没有循环机制的帮助)。
尝试/重试每一段代码既不明智也不实用。相反,尝试操作只应在绝对必要且希望提供应用程序最大可用性和弹性时进行。
艰难时期:从何开始?
当最初确定您需要在一个**尝试**代码的类中做什么和包含什么时,最好看到一个非抽象版本,它至少部分地展示了我们试图解决的问题。
private static void TryMyCode()
{
string myFilename = "test.dat";
string fileResults = null;
int count = 10;
try
{
while (count > 0)
{
try
{
fileResults = File.ReadAllText(myFilename);
break;
}
catch(IOException)
{
count--;
if (count == 0)
{
throw;
}
Thread.Sleep(1000);
}
}
Console.WriteLine(fileResults);
}
catch (Exception ex)
{
Console.WriteLine("Cannot read file after 10 tries.");
}
}
从这个例子中,您可以看到我们真正想做的就是读取文件中的所有文本。这段代码的其余部分是必需的“废话”,以便读取操作能够最大限度地抵御外部影响,例如网络故障或由于其他进程导致的锁定。例如,假设一个进程写入“test.dat”文件,但这段示例代码包含在另一个将要读取它的进程中。写入文件的主进程可能还没有完成写入文件,但您需要知道它何时完成。由于 .NET Framework 没有提供正式的方法来确定文件何时可供读取,或者更具体地说,何时不存在阻止读取的锁,那么这种类型的代码在某些情况下是合适的。但是,您不希望永远尝试(或者您可能希望);也许尝试十次就足够了,每次尝试之间等待一秒钟。
这里的最终结果在技术上是可读的,但它无疑模糊了真正的意图,即 `fileResults = File.ReadAllText(myFilename)` 或“我想读取文件中的所有文本”。此外,这段代码还存在其他软件开发弊病,因为它既不可移植也不可重用。每次需要执行此类操作时,都必须重新创建它。
对代码的快速分析将揭示一些可能为抽象提供良好基础的关键特性:
- 我们需要执行一个或多个语句并捕获所有异常。
- 如果特定条件尚未满足,我们需要重复这些语句。
- 如果条件已满足,我们可能需要抛出异常(或原始异常)。
- 我们可能需要忽略某些异常,或者某些类型的异常可能被视为“成功”。
- 我们可能需要在两次尝试之间等待。
那么,如何抽象这段代码使其更具可重用性呢?一种可能的答案
private static void TryMyCode2()
{
string myFilename = "test.dat";
try
{
string results = TryCode10(() => File.ReadAllText(myFilename));
Console.WriteLine(results);
}
catch (Exception ex)
{
Console.WriteLine("Cannot read file after 10 tries.");
}
}
private static T TryCode10<T>(Func<T> tryAction)
{
int count = 10;
while (count > 0)
{
try
{
return tryAction();
}
catch (IOException)
{
count--;
if (count == 0)
{
throw;
}
Thread.Sleep(1000);
}
}
throw new IOException("Cannot read from file.");
}
请注意,在这种情况下,我们创建了一个静态方法,该方法将执行提供的委托(在本例中为 `Func
这段代码的可重用性略高。从文件中获取文本的方法,`TryMyCode2` 方法,更简单,更容易理解。然而,它仍然存在一些关键缺点。首先,计数逻辑封装在 `TryCode10` 方法中,因此为了使它真正成为一个可重用方法,**计数**必须作为参数添加;其他几个关键值也需要作为参数添加(我们将在后面讨论),这反过来会使方法调用更难理解其意图。其次,这段代码还存在另一个缺点:它不包含尝试代码片段的其他要求,例如渐进式等待时间或开发软件时出现的其他不可预见的情况。第三,它似乎专门用于捕获 `IOException` 异常而不是通用异常,这可能会限制此方法的实用性。
也许还有更易读的方法:
private static void TryMyCode3()
{
string myFilename = "test.dat";
Results<string> results = Trier.Try(TimeSpan.FromSeconds(1), 10, () => File.ReadAllText(myFilename));
if (results.Success)
{
Console.Write(results.Result);
}
else
{
Console.WriteLine("Cannot read file after 10 tries");
}
}
这无疑更易于阅读,使我们更接近清洁代码的目标,即易于阅读、维护和理解的代码。请注意,我们的 `File.ReadAllText(string)` 方法不会引发异常(至少代码示例是这样描绘的)。相反,我们得到了一个方便的属性,我们可以查询它来确定方法执行是否成功。另外(未显示),返回的结果应该有一个 Exceptions 属性,该属性将提供由于代码执行可能生成的一个或多个异常。
尝试器
我们可以通过两种方式开发尝试器。首先,我们可以创建一个接口,如果您愿意,可以称为 `ITrier`,或者我们可以创建一个抽象类。第一个选项听起来很有吸引力,因为现在任何类都可以成为“代码尝试器”,但正如我们将看到的,抽象类的第二个选项提供了更好的抽象性和可重用性,其原因类似于 .NET Framework 提供的 Stream 或 `TextReader` 类本身是抽象类而不是接口的原因。
public abstract class Trier
{
protected abstract bool CanTry { get; }
public bool HasTried { get; protected set; }
protected virtual void OnTry(Exception exception) { }
protected virtual bool GetIsBlacklisted(Exception ex)
{
return false;
}
protected virtual bool GetIsIgnored(Exception ex)
{
return false;
}
protected virtual bool ThrowFinalException
{
get
{
return true;
}
}
protected virtual bool ThrowOnBlacklistedException
{
get
{
return true;
}
}
}
为了与我们之前提到的尝试器必备列表保持一致,我们需要一个属性,派生尝试器可以重写该属性以确定它是否“可以尝试”某个操作;如果我们可以再次尝试代码,则为 true;如果不能,则为 false;false 意味着已满足某些条件,阻止我们进一步执行代码。
由于尝试器必然会提供“状态”,例如计数、时间延迟等,因此我们绝不能尝试重用旧的尝试器。既然您可以简单地创建一个新的,就没有理由“重置”现有的尝试器并重用它;拥有一个指示尝试器是否已被使用过的属性,即 HasTried,对于最终将使用尝试器定义的**规则**来**尝试**其所需代码的代码来说非常有用。
派生类可能还想知道操作是否失败以及是否会进行另一次尝试。这就是 `OnTry(Exception)` 方法派上用场的地方。派生类可能不关心操作是否已尝试。然而,尝试器可能希望将尝试记录到某个日志或消息系统中,此方法为此行动方案提供了绝佳的机会。
尝试器应该有一个异常**黑名单**的概念。那些具有毁灭性、不可恢复或有害的异常,一旦发生,就绝对不能再尝试;代码只需要失败;因此有了 `GetIsBlackListed` 虚方法。此方法将允许派生类型确定在尝试过程中发生的特定异常是否被视为黑名单。
尝试器也需要一个白名单。白名单是那些可以安全忽略的异常。事实上,白名单或被忽略的异常可能被视为尝试代码的**可接受**的成功,因此您不希望仅仅因为发生了这种类型的异常而导致尝试代码失败。多年来,我在无法控制的遗留代码中多次看到这种情况,但会生成一个异常,这是预期且无关紧要的。
接下来,我们希望拥有一个 `ThrowOnBlacklistedException` 属性。这允许我们停止尝试代码并抛出生成的异常。每个尝试器都有责任根据生成的异常确定尝试代码的有效性。
最后,我们有一个 `ThrowFinalException` 属性。如果代码在尝试时完全失败,此属性确定是否应抛出生成的最终异常,或者像前面的示例代码一样只返回一些结果对象实例。
它能有多难?
所有这些都很有趣,但它没有提供使用尝试器尝试代码的方法。为此,我们需要在抽象尝试器类本身中有一个静态的 `Try` 方法。这提供了几个关键优势。首先,由于这是一个抽象类,并且我们既有私有方法也有受保护方法,因此抽象类上的任何静态方法都可以访问派生尝试器实例的这些方法。这提供了出色的逻辑封装。
让我们检查一下我们将添加到尝试器抽象类中的两个静态方法:
public static Results Try(Trier trier, Action method)
{
return Try<object>(trier, () => { method(); return null; });
}
public static Results<T> Try<T>(Trier trier, Func<T> method)
{
AssertTrierHasNotBeenTried(trier);
List<Exception> exceptions = new List<Exception>();
trier.HasTried = true;
do
{
try
{
return new Results<T>(method());
}
catch (Exception ex)
{
if (trier.GetIsIgnored(ex))
{
break;
}
exceptions.Add(ex);
if (trier.GetIsBlacklistedAndThrow(ex))
throw;
trier.OnTry(ex);
if (trier.CanTry)
continue;
if (trier.ThrowFinalException)
throw;
break;
}
} while (true);
return new Results<T>(exceptions);
}
首先突出的一点是,这两个方法接受一个要尝试的方法,并将一些结果返回给调用者。为简洁起见,我已将 Results 类排除在本文之外,但您可以下载提供的代码示例来查看它们。
Results 和 `Results
这是少数几个全局异常处理程序适用情况之一。通常,尝试捕获所有异常是不合适的,但在这里,它至关重要,这样我们就可以使用该异常对象来测试尝试器中可能存在的各种规则,以便我们能够采取相应的行动。
您会注意到我们进入了一个无限循环,然后让尝试器通过 `Trier.CanTry` 属性告知我们何时无法继续。此外,如果异常被列入黑名单或被忽略,也可以发生其他操作。
我应该尝试多少次:计次尝试器
我想所有这些看起来都很巧妙,但是您可以用这段代码做什么呢?以下代码片段定义并演示了如何使用 `CountedTrier`。这个代码尝试器会计算失败次数,并在达到最大次数时停止。它也成为更高级尝试器的基础。
public class CountedTrier : Trier
{
public CountedTrier(int maximumTries)
{
MaximumTries = maximumTries;
}
/// <summary>
/// Gets the maximum number of tries to be performed before failing.
/// </summary>
public int MaximumTries { get; private set; }
/// <summary>
/// Gets a count of the number of tries that were made.
/// </summary>
public int Count { get; private set; }
protected override bool CanTry
{
get
{
Count++;
return Count < MaximumTries;
}
}
}
public class Program
{
public static void Main(string[] args)
{
var filename = "results.txt";
var trier = new CountedTrier(10);
var fileText = Trier.Try(
trier,
() =>
{
return File.ReadAllText(filename);
}
);
}
}
这段代码示例比我们之前的同类示例更具可读性。虽然它仍然没有达到应有的实用程度,但我相信您现在已经理解了它潜在的价值。
在我们的示例中,我们创建了一个 `CountedTrier` 类,它仅在调用 `Trier.CanTry` 方法时递增一个值,如果计数小于 MaximumTries 属性(在我们的示例中为 10),则返回 true,如果计数多一次,则返回 false。
当然,这个例子会失败,而且它没有解决其他问题,比如等待一段时间,也没有确定哪些异常应该真正抛出,哪些异常应该允许它再次尝试。
在结束本文之前,让我们看看代码示例中包含的另外两个尝试器。
也许我应该在再次尝试之前等待:一个延迟尝试器
这种类型的尝试器扩展了我们之前创建的 `CountedTrier`,允许我们在连续尝试之间延迟一段时间。
/// <summary>
/// Allows trying an operation a specified interval over a maximum try count.
/// </summary>
public class DelayedTrier : CountedTrier
{
public DelayedTrier(TimeSpan interval, int maximumTries)
: base(maximumTries)
{
Interval = interval;
}
/// <summary>
/// Gets the interval to wait between each try operation.
/// </summary>
public TimeSpan Interval { get; protected set; }
protected override bool CanTry
{
get
{
bool canTry = base.CanTry;
if (canTry)
System.Threading.Thread.Sleep(Interval);
return canTry;
}
}
}
下一个示例是 `DelayedTrier` 的另一种实现。它不再是每次连续尝试之间都等待固定的时间,而是等待递增或渐进的时间量。换句话说,每次连续尝试之间等待的时间量将在每个间隔进行计算。用于确定新间隔的函数将由提供的 Delayed 委托定义。此类别进一步扩展了 `DelayedTrier` 以提供此功能。
/// <summary>
/// Allows trying an operation a specified interval with a function that determines a new delay between tries over a maximum number of tries.
/// </summary>
public class ProgressiveTrier : DelayedTrier
{
private readonly Delayed _newDelay;
public ProgressiveTrier(TimeSpan initialInterval, int maximumTries, Delayed newDelay)
: base(initialInterval, maximumTries)
{
AssertNewDelayNotNull(newDelay);
_newDelay = newDelay;
}
protected override bool CanTry
{
get
{
bool canTry = base.CanTry;
if (canTry)
{
Interval = _newDelay(Interval, Count);
}
return canTry;
}
}
private static void AssertNewDelayNotNull(Delayed newDelay)
{
if (newDelay == null)
{
throw new ArgumentNullException("newDelay");
}
}
}
/// <summary>
/// A method used to adjust the interval between successive tries.
/// </summary>
/// <param name="interval">The current interval between try operations.</param>
/// <param name="count">The number of tries that have already occured.</param>
/// <returns>A new <see cref="TimeSpan"/> that is the new interval to wait before the next try operation.</returns>
public delegate TimeSpan Delayed(TimeSpan interval, int count);
用法:
//Progressive Trier example
//This code should wait a new interval based on the count of the number of tries.
//It should take ~4 minutes and 40 seconds to completely fail.
{
var filename = "results.txt";
var trier = new ProgressiveTrier(TimeSpan.FromSeconds(5), 10, (interval, count) => TimeSpan.FromSeconds(5 * count));
var fileText = Trier.Try(
trier,
() =>
{
return System.IO.File.ReadAllText(filename);
}
);
}
结论
如所示,可以简化复杂逻辑来尝试代码片段,直到它们通过或失败。还可以将该逻辑从被尝试的代码片段中抽象到更合适的场所,以提供卓越的可重用性和清晰的维护性。
我欢迎对此主题的评论、建议和批评。请随时给我反馈。
历史
- 版本 1.0 - 初始发布。