具有调用方定义的错误处理策略的单一实现

Single implementation with caller-defined error handling strategy

提问人:Zoey Hewll 提问时间:11/7/2023 最后编辑:Zoey Hewll 更新时间:11/9/2023 访问量:30

问:

我想使用几种不同的策略来处理错误,从而实现一个复杂的操作。为了简单起见,假设该操作是批量文件删除。 如果删除文件时出错,我可能需要做一些事情来响应:

  • 立即退出,不要再删除任何文件
  • 记录失败并继续删除其他文件
  • 将失败添加到某些失败删除的集合中并继续,并在操作完成后返回该集合

这似乎不太适合例外和尝试捕获,因为它迫使我预先决定策略,这意味着我将为每个策略提供不同版本的核心实现。

使用异常的示例:

// Quitting strategy
void DeleteAllQuitting(IEnumerable<FileInfo> files) {
    foreach (var file in files) {
        file.Delete();
    }
}

// Logging strategy
void DeleteAllLogging(IEnumerable<FileInfo> files, ILogger log) {
    foreach (var file in files) {
        try {
            file.Delete();
            log.Info(file);
        } catch (IOException e) {
            log.Warn(file, e);
        } catch (SecurityException e) {
            log.Warn(file, e);
        }
    }
}

// Collecting strategy
Failure[] DeleteAllCollecting(IEnumerable<FileInfo> files) {
    return files.Select(file => {
        try {
            file.Delete();
            return new Success(file, e);
        } catch (IOException e) {
            return new Failure(file, e);
        } catch (SecurityException e) {
            return new Failure(file, e);
        }
    }).OfType<Failure>().ToArray();
}

它们都具有相同的核心逻辑(调用每个元素),仅在如何处理该核心操作的异常方面有所不同。 每个新战略都需要一个全新的实施。 当然,在这种情况下,重复是最小的,但希望你能想象更复杂的操作,其中重复将非常重要(例如递归删除目录)。file.Delete()

我能想到的最好的办法是使用生成器,在操作失败或成功时懒惰地产生结果,允许调用者仅使用一个核心实现中止、恢复、记录、收集或任何其他可能的策略。 但我在 C# 标准库中没有遇到过这样的东西,所以我的印象是这不是惯用的,也许我错过了更好的方法。

使用生成器的示例:

// Core implementation
IEnumerable<Result> DeleteAll(IEnumerable<FileInfo> files) {
    foreach (var file in files) {
        Result result;
        try {
            file.Delete();
            result = new Success(file);
        } catch (Exception e) {
            result = new Failure(file, e);
        }
        yield return result;
    }
}

// Quitting strategy
void DeleteAllQuitting(IEnumerable<FileInfo> files) {
    foreach (var fail in DeleteAll(files).OfType<Failure>()) {
        throw fail.exception;
    }
}

// Logging strategy
void DeleteAllLogging(IEnumerable<FileInfo> files, ILogger log) {
    foreach (var result in DeleteAll(files)) {
    switch (result){
        case Failure f:
            var e = f.exception;
            if (e is IOException || e is SecurityException) {
                log.Warn(f.file, e);
            } else {
                throw e;
            }
            break;
        case Success s:
            log.Info(s.file);
            break;
    }
}

// Collecting strategy
Failure[] DeleteAllCollecting(IEnumerable<FileInfo> files) {
    return DeleteAll(files)
        .OfType<Failure>()
        .Where(fail => fail.exception is IOException || fail.exception is SecurityException)
        .ToArray();
}

作为替代方法,我还考虑创建一个提供 OnFail 和 OnSuccess 方法的接口,这些方法将传递给 DeleteAll 方法。 缺点是,它需要实现者定义他们自己的类(我认为不能像我所做的那样与代码一起嵌套),并且它会反转控制流,因此调用者中的本地控制流需要是处理程序类中的手写状态机。

// Core implementation
void DeleteAll(IEnumerable<FileInfo> files, IErrorStrategy handler) {
    foreach (var file in files) {
        Exception ex = null;
        try {
            file.Delete();
        } catch (Exception e) {
            ex = e;
        }
        if (ex is null) {
            handler.success(file);
        } else {
            handler.fail(file, ex);
        }
    }
}

// Quitting strategy
void DeleteAllQuitting(IEnumerable<FileInfo> files) {
    class Thrower : IErrorStrategy {
        void fail(FileInfo _, Exception e) {
            throw e;
        }
    }
    DeleteAll(files, new Thrower());
}

// Logging strategy
void DeleteAllLogging(IEnumerable<FileInfo> files, ILogger log) {
    class Logger : IErrorStrategy {
        void success(FileInfo file) {
            log.Info(file, e);
        }
        void fail(FileInfo file, Exception e) {
            if (e is IOException | e is SecurityException) {
                log.Warn(file, e);
            } else {
                throw e;
            }
        }
    }
    DeleteAll(files, new Logger());
}

// Collecting strategy
Failure[] DeleteAllCollecting(IEnumerable<FileInfo> files) {
    class Collector : IErrorStrategy {
        List<Exception> errors;
        
        void fail(FileInfo file, Exception e) {
            if (e is IOException | e is SecurityException) {
                errors.Add(e)
            } else {
                throw e;
            }
        }
    }
    var collector = new Collector();
    DeleteAll(files, collector);
    return collector.errors;
}

请原谅这个漫无边际的问题,但我想它的核心是:有没有我错过的设计做得更好?或者发电机解决方案是我们拥有的最好的解决方案吗?

补遗:

关于我所说的“倒置控制流”这个话题, 它不太可能出现在我上面描述的情况中, 但这个概念是,生成或使用一系列值的任意代码可以表示为内部或外部迭代器。

  • 外部迭代器的特征是从序列中提取(我反复调用迭代器 - 尽管在 C# 中将其拆分为枚举器和枚举器)。it.next()it.Currentit.MoveNext()
  • 内部迭代器的特点是依次将每个项目推送到它(我的方法被反复调用)。with_next(Item)

一个可以被认为是另一个的反转,因为理论上可以将任意外部迭代器转换为等效的内部迭代器,反之亦然。

当前者可以表示为过程代码时,后者被表示为状态机,以便“记住”它在控制流中的位置。 (我理解 C# 生成器方法(即 )实际上出于同样的原因被编译为状态机)yield return

在大多数简单的 for 循环中,它们看起来基本相同: 作为外部迭代器:

for (var item in iter) {
    do_something(item);
}
// compiles to something like
while (iter.has_next()) {
    do_something(it.next());
}

作为内部迭代器:

iter.ForEach(new Foo());

class Foo {
    void with_next(Item item) {
        do_something(item);
    }
}

但是使用任意逻辑,它可能会变得更加复杂。我在下面提供了一个相当病态的例子,但我的主要目标是让呼叫者自由,这应该是一个有效的策略。 作为外部迭代器:

if (a(it.next())) {
    b(it.next());
    c(it.next());
} else {
    d(it.next());
}

作为内部迭代器:(请原谅,因为我不习惯用 C# 手写状态机)

try {
    iter.ForEach(new Foo());
} catch Done; // exit early if the state machine asks to

enum Step {
    Test,
    Pass1,
    Pass2,
    Fail1,
    End,
}
class Foo {
    Step current_step = Step.Test;

    void with_next(Item item) {
        switch (current_step) {
            case Step.Test: 
                current_step = a(item) ? Step.Pass1 : Step.Fail1;
                break;
            case Step.Pass1:
                b(item);
                current_step = Step.Pass2;
                break;
            case Step.Pass2:
                c(item);
                current_step = Step.End;
                break;
            case Step.Fail1:
                d(item);
                current_step = Step.End;
                break;
            case Step.End:
                throw new Done());
        }
    }
}

回到上面概述的问题:

  • 提供 的版本严格对应于内部迭代:处理程序上的方法大致对应于内部迭代器中的专用方法。IErrorHandlerwith_next
  • 迭代惰性结果的版本实际上可以免费使用外部或内部迭代。Collecting 变体确实通过 LINQ 使用内部迭代,而其他变体则通过循环使用外部迭代。foreach
C# 错误处理

评论

1赞 Sweeper 11/7/2023
“要求实现者定义自己的类”有什么问题?您也可以只使用委托类型,例如 。调用方的代码可以使用 lambda 并且不那么冗长。另外,你说的“反转控制流”是什么意思?您认为控制流程应该是什么样子的?Action<FileInfo, Exception>
0赞 Perringaiden 11/7/2023
正如@Sweeper所说,你为什么要反对调用方定义异常策略。但是,还有其他几个选项: * 返回一个 Success/Fail 对象,该对象允许用户决定事后要做什么。* 创建一个自定义异常,该异常收集所有文件及其异常,然后在处理完所有文件后引发,其中包含异常集合。问题是,这完全取决于你想控制谁来控制异常处理。如果它是类内部的,则必须事先定义它。如果它在外面定义,你必须把它传进去,或者接住。
0赞 Zoey Hewll 11/7/2023
我对 Sweeper 评论的回应可能需要一段时间。我现在可以回答第二条评论:您提供的两个选项似乎都仅限于一个或另一个,即在让呼叫者干预之前始终做尽可能多的工作,或者总是在出现问题时立即取消,但呼叫者无法在它们之间做出决定。并回答您评论的最后一部分:我希望呼叫者对处理策略拥有最大的控制权。我把不同的策略作为方法与核心方法一起展示,只是因为它很方便,但这可以在用户代码中。
0赞 Fildor 11/7/2023
做出决定不是已经是用户干预了吗?
0赞 Fildor 11/7/2023
另一方面:您是否一直在研究 Polly,如果不使用它,也许看看他们是如何做到的?

答: 暂无答案