特殊情况的架构:第2页,共4页

我猜这个主题中最重要的问题之一是在应用程序中构建异常处理体系结构。 出于许多原因,这很有趣。 我认为主要原因是表面上的简单性,您并不总是知道该怎么做。 所有基本结构,例如IEnumerableIDisposableIObservable等。 拥有此属性,并在各处使用它。 一方面,它们的简单性试图在不同情况下使用这些构造。 另一方面,它们充满了陷阱,您可能无法摆脱。 看看我们将提供的信息量,您可能会遇到一个问题:特殊情况有何特别之处?


但是,要得出有关构建异常类的体系结构的结论,我们应该学习有关其分类的一些详细信息。 因为在构建对代码用户而言清楚的类型系统之前,程序员应该确定何时选择错误类型以及何时捕获或跳过异常。 因此,让我们根据各种功能对特殊情况(而不是异常类型)进行分类。


基于理论上的可能性来捕获将来的异常。


基于此功能,我们可以将异常分为肯定会捕获的异常和极有可能不会捕获的异常。 为什么我说可能性很高 ? 因为总是有人会尝试捕获异常,而这是不必要的。


首先,让我们描述第一类异常-应该捕获的异常。


一方面,在发生此类异常的情况下,我们对子系统说,当我们的数据没有采取进一步行动时,我们就进入了某种状态。 另一方面,我们的意思是没有灾难性事件发生,我们可以通过简单地捕获异常来找到摆脱困境的出路。 该属性非常重要,因为它定义了错误的严重性,并且使我们确信,如果我们捕获到异常并清除了资源,则可以继续执行代码。


第二组处理的例外情况虽然听起来可能很奇怪,但不必抓住。 它们只能用于错误日志记录,而不能用于纠正情况。 最简单的示例是ArgumentExceptionNullReferenceException 。 实际上,在通常情况下,您不需要捕获例如ArgumentNullException因为在这种情况下,错误的源头就是您。 如果捕获到此类异常,则承认您犯了一个错误,并向方法传递了一些不可接受的内容:


 void SomeMethod(object argument) { try { AnotherMethod(argument); } catch (ArgumentNullException exception) { // Log it } } 

在此方法中,我们尝试捕获ArgumentNullException 。 但是我认为这很奇怪,因为将正确的参数传递给方法完全是我们的关注。 事件发生后做出反应是不正确的:在这种情况下,您最好的办法是在调用方法之前预先检查所传递的数据,甚至构建不可能获取错误参数的代码。


另一类特殊情况是致命错误。 如果某些高速缓存有故障,并且子系统的工作仍然不正确,则这是一个致命错误,并且堆栈上最近的代码肯定不能捕获它:


 T GetFromCacheOrCalculate() { try { if(_cache.TryGetValue(Key, out var result)) { return result; } else { T res = Strategy(Key); _cache[Key] = res; return res; } } cache (CacheCorruptedException exception) { RecreateCache(); return GetFromCacheOrCalculate(); } } 

CacheCorruptedException是一个异常,表示“硬盘驱动器缓存不一致”。 然后,如果此类错误的原因对于缓存子系统是致命的(例如,没有缓存文件访问权限),则以下代码无法使用RecreateCache指令重新创建缓存,因此捕获此异常本身就是错误。


根据实际发现异常情况的区域


另一个问题是我们应该捕获一些异常还是将其传递给对情况更了解的人。 换句话说,我们应该建立责任领域。 让我们检查以下代码:


 namespace JetFinance.Strategies { public class WildStrategy : StrategyBase { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { ?try? { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { } } } } using JetFinance.Strategies; using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); ?try? { boo.DoSomethingWild(); } catch(StrategyException exception) { } } 

两种策略中哪一种更合适? 责任范围非常重要。 最初, WildInvestment的工作和一致性WildInvestment完全取决于WildStrategy 。 因此,如果WildInvestment只是忽略了此异常,它将进入更高级别,我们不应该做任何事情。 但是,请注意,就体系结构而言, Main方法从一个级别捕获一个异常,而从另一个级别调用该方法。 在使用方面看起来如何? 好吧,这就是它的样子:


  • 该例外的责任已移交给我们;
  • 此类的用户不确定此异常先前是否是有意通过一组方法传递的;
  • 我们开始创建新的依赖关系,我们通过调用中间层摆脱了这些依赖关系。

但是,由此得出的另一个结论是:我们应该在DoSomethingWild方法中使用catch 。 这对我们来说有点奇怪: WildInvestment几乎不依赖某种东西。 我的意思是,如果PlayRussianRoulette无法正常工作, DoSomethingWild也会遇到同样的问题:它没有返回码,但必须玩轮盘赌。 那么,在这种看似绝望的情况下我们能做什么? 答案实际上很简单:在另一个级别上, DoSomethingWild应该抛出属于该级别的自己的异常,并将其包装在InnerException作为问题的原始来源:


 namespace JetFinance.Strategies { pubilc class WildStrategy { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { try { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { throw new FailedInvestmentException("Oops", exception); } } } public class InvestmentException : Exception { /* .. */ } public class FailedInvestmentException : Exception { /* .. */ } } using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); try { boo.DoSomethingWild(); } catch(FailedInvestmentException exception) { } } 

通过将一个异常包装在另一个异常中,我们将问题从一个应用程序级别转移到另一个应用程序级别,并根据该类的使用者( Main方法)使它的工作更可预测。


基于重用问题


通常,我们懒于创建新的异常类型,但是当我们决定这样做时,并不总是清楚要基于哪种异常。 但是正是这些决定定义了特殊情况的整体架构。 让我们看一些流行的解决方案并得出一些结论。


在选择异常类型时,我们可以使用先前制作的解决方案,即找到名称包含相似含义的异常并使用它。 例如,如果我们通过参数获得一个实体,但我们不喜欢该实体,则可以抛出InvalidArgumentException ,以指示Message中错误的原因。 这种情况看起来特别好,因为InvalidArgumentException位于可能无法捕获的异常组中。 但是,如果使用某些数据类型,则InvalidDataException的选择将是错误的。 这是因为此类型在System.IO区域中,可能不是您要处理的。 因此,搜索现有类型而不是自己开发一个类型几乎总是错误的。 一般任务范围几乎没有例外。 实际上,所有这些都是针对特定情况的,如果在其他情况下重用它们,则会严重违反特殊情况的体系结构。 此外,特定类型的异常(例如System.IO.InvalidDataException )可能会使用户感到困惑:一方面,他将看到该异常属于System.IO命名空间,而另一方面,它是从系统抛出的完全不同的名称空间。 如果该用户开始考虑引发此异常的规则,则可以转到referencesource.microsoft.com并查找引发异常的所有位置


  • internal class System.IO.Compression.Inflater

用户将了解 有人都是大拇指 这种类型的异常使他感到困惑,因为引发该异常的方法没有处理压缩。


同样,就重用而言,您可以简单地创建一个异常并在其中声明ErrorCode字段。 这似乎是个好主意。 您只需要抛出相同的异常,设置代码,然后仅使用一个catch即可处理异常,从而提高了应用程序的稳定性,仅此而已。 但是,我相信您应该重新考虑这一立场。 当然,这种方法一方面使生活更轻松。 但是,另一方面,您忽略了捕获具有某些共同特征的异常子组的可能性。 例如, ArgumentException通过继承将一堆异常合并在一起。 另一个严重的缺点是太大且无法读取的代码,必须安排基于错误代码的过滤。 但是,当用户不必担心指定错误时,使用错误代码引入包含类型会更合适。


 public class ParserException { public ParserError ErrorCode { get; } public ParserException(ParserError errorCode) { ErrorCode = errorCode; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket, // ... } // Usage throw new ParserException(ParserError.MissingModifier); 

保护解析器调用的代码不在乎为什么解析失败:它对这样的错误感兴趣。 但是,如果失败的原因毕竟很重要,则用户始终可以从ErrorCode属性中获取错误代码。 而且,您实际上不必在Message的子字符串中搜索必要的单词。


如果我们不选择重用,则可以为每种情况创建一种异常类型。 一方面,这听起来合乎逻辑:一种错误类型-一种异常类型。 但是,请不要过度使用:拥有过多类型的异常将导致捕获问题,因为调用方法的代码将被catch块所重载。 因为它需要处理要传递给它的所有类型的异常。 另一个缺点是纯粹的建筑。 如果您不使用例外,则会使使用这些例外的人感到困惑:他们可能有很多共同点,但将被单独捕获。


但是,在特定情况下,有很多方案可以引入单独的类型。 例如,当错误不影响整个实体,而是特定方法时。 然后,这种错误类型应该在继承的层次结构中占据一席之地,以至于没有人会想到将其与其他东西一起捕获:例如,通过单独的继承分支。


同样,如果将这两种方法结合使用,则可以使用一组功能强大的工具来处理一组错误:可以引入通用的抽象类型并从中继承特定的情况。 基类(我们的通用类型)必须获得一个抽象属性,该属性旨在存储错误代码,而继承者将通过覆盖此属性来指定此代码。


 public abstract class ParserException { public abstract ParserError ErrorCode { get; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket } public class MissingModifierParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingModifier; } public class MissingBracketParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingBracket; } // Usage throw new MissingModifierParserException(ParserError.MissingModifier); 

使用这种方法,我们可以获得一些很棒的属性:


  • 一方面,我们不断使用基本(通用)类型捕获异常;
  • 另一方面,即使捕获这种基本类型的异常,我们仍然能够确定特定情况;
  • 另外,我们可以通过特定类型而不是基本类型来捕获异常,而无需使用类的平面结构。

我觉得很方便。


基于属于特定的行为情况组


根据先前的推理,我们可以得出什么结论? 让我们尝试定义它们。


首先,让我们决定什么意味着情况? 通常,我们使用具有某种内部状态的实体来讨论类和对象,并且可以对这些实体执行操作。 因此,第一类行为情况包括对某个实体的动作。 接下来,如果从外部查看对象图,我们将看到它在逻辑上表示为功能组的组合:第一个组处理缓存,第二个组处理数据库,第三个组处理数学计算。 不同的层可以遍历所有这些组,例如内部状态记录,过程记录和方法调用跟踪的层。 层可以包含几个功能组。 例如,可以有一个模型层,一个控制器层和一个表示层。 这些组可以在一个程序集中或在不同的程序集中,但是每个组可以创建自己的特殊情况。


因此,我们可以根据异常情况的类型属于一个或另一个组或层来构建层次结构。 因此,我们允许捕获代码在层次结构中的这些类型之间轻松导航。


让我们检查以下代码:


 namespace JetFinance { namespace FinancialPipe { namespace Services { namespace XmlParserService { } namespace JsonCompilerService { } namespace TransactionalPostman { } } } namespace Accounting { /* ... */ } } 

怎么了 我认为名称空间是一种根据行为情况自然地将异常类型进行分组的完美方法:属于特定组的所有内容都应保留在其中,包括异常。 此外,当您遇到特定的异常时,您将看到其类型的名称以及将指定其所属组的名称空间。 您还记得实际上在System.IO名称空间中定义的InvalidDataException重用吗? 它属于此名称空间的事实意味着可以从System.IO名称空间中或嵌套得更多的类中引发此类异常。 但是实际的异常是从一个完全不同的空间抛出的,使处理此问题的人员感到困惑。 但是,如果将异常类型和引发这些异常的类型放在相同的名称空间中,则会使类型的体系结构保持一致,并使开发人员更容易理解发生这种情况的原因。


在代码级别进行分组的第二种方法是什么? 继承:


 public abstract class LoggerExceptionBase : Exception { protected LoggerException(..); } public class IOLoggerException : LoggerExceptionBase { internal IOLoggerException(..); } public class ConfigLoggerException : LoggerExceptionBase { internal ConfigLoggerException(..); } 

请注意,对于普通的应用程序实体,它们继承行为和数据以及属于单个实体组的组类型。 但是,对于异常,它们是根据一组情况继承并分组的,因为异常的本质不是实体而是问题。


结合这两种分组方法,我们可以得出以下结论:


  • 程序集内应该有一个基本类型的异常,该程序集将抛出该异常。 这种类型的异常应该在程序集的根名称空间中。 这将是分组的第一层。
  • 此外,程序集中可以有一个或几个命名空间。 它们中的每一个都将程序集划分为功能区,以定义出现在该程序集中的情况组。 这些可能是控制器,数据库实体,数据处理算法等区域。 对我们来说,这些名称空间意味着根据其功能对类型进行分组。 但是,就例外而言,它们是基于同一程序集中的问题进行分组的;
  • 异常必须从同一高层名称空间中的类型继承。 这确保了最终用户将明确理解情况,并且不会捕获基于错误类型的异常。 承认,通过catch(global::Legacy.LoggerExeption exception)捕获global::Finiki.Logistics.OhMyException是很奇怪的catch(global::Legacy.LoggerExeption exception) ,而以下代码看起来绝对合适:

 namespace JetFinance.FinancialPipe { namespace Services.XmlParserService { public class XmlParserServiceException : FinancialPipeExceptionBase { // .. } public class Parser { public void Parse(string input) { // .. } } } public abstract class FinancialPipeExceptionBase : Exception { } } using JetFinance.FinancialPipe; using JetFinance.FinancialPipe.Services.XmlParserService; var parser = new Parser(); try { parser.Parse(); } catch (XmlParserServiceException exception) { // Something is wrong in the parser } catch (FinancialPipeExceptionBase exception) { // Something else is wrong. Looks critical because we don't know the real reason } 

在这里,用户代码调用一个库方法,众所周知,该方法在某些情况下会引发XmlParserServiceException 。 而且,正如我们所知,此异常是指继承的命名空间JetFinance.FinancialPipe.FinancialPipeExceptionBase ,这意味着可能还有其他异常-这次XmlParserService微服务仅创建一个异常,但将来可能会出现其他异常。 由于我们有创建异常类型的约定,因此我们知道该新异常将继承自哪个实体,并预先放置了一个包围式catch 。 这使我们能够跳过所有与我们无关的事情。


如何建立这样的类型层次结构?


  • 首先,我们应该为域创建一个基类。 我们称其为域基类。 在这种情况下,域是一个单词,它包含许多程序集,并基于某些功能将它们组合在一起:日志记录,业务逻辑,UI。 我的意思是应用程序的功能区尽可能大。
  • 接下来,我们应该为必须捕获的异常引入一个额外的基类:所有使用catch关键字捕获的异常都将从该基类继承;
  • 所有指示致命错误的异常都应直接从域基类继承。 因此,我们将它们与那些在体系结构级别上捕获的对象分开;
    -根据名称空间将域划分为功能区域,并声明将从每个区域引发的异常的基本类型。 这里有必要使用常识:如果应用程序具有高度的名称空间嵌套,则不应为每个嵌套级别都做一个基本类型。 但是,当一组异常进入一个名称空间而另一组异常进入另一个名称空间时,如果在嵌套级别进行分支,则必须为每个子组使用两种基本类型。
  • 特殊异常应从属于功能区域的异常类型继承
  • 如果可以组合一组特殊异常,则有必要在一个以上的基本类型中进行处理:这样您就可以更轻松地捕获它们;
  • 如果您认为使用基类会更频繁地捕获该组,请引入带有ErrorCode的混合模式。

根据错误源


错误的来源可能是将异常组合成组的另一个基础。 例如,如果您设计一个类库,则以下内容可以构成源组:


  • 错误的不安全代码调用。 通过将异常或错误代码包装在其自己的异常类型中,同时将返回的数据(例如原始错误代码)保存在异常的公共属性中,可以解决这种情况。
  • 由外部依赖项进行的代码调用,它引发了我们的库无法捕获的异常,因为它们超出了其职责范围。 该组可以包括被接受为当前方法的参数的那些实体的方法中的异常,也可以包括来自类的构造函数的异常(该方法称为外部依赖项)。 例如,我们类的方法调用了另一个类的方法,该类的实例是通过另一个方法的参数返回的。 如果异常表明我们是问题的根源,则应生成自己的异常,同时将原始异常保留在InnerExcepton 。 但是,如果我们了解问题是由外部依赖性引起的,那么我们将忽略此异常,因为它属于我们无法控制的一组外部依赖性。
  • 我们自己的代码被意外地置于不一致状态。 文本分析就是一个很好的例子-没有外部依赖关系,没有转移到unsafe世界,但是发生了解析问题。

本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。

另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库 github / sidristij / dotnetbook

Source: https://habr.com/ru/post/zh-CN454882/


All Articles