逐级实施回退

Implementing level by level fallback

我有一个 class ScoreStrategy 描述了如何计算测验分数:

public class ScoreStrategy
{
    public int Id { get; set; }

    public int QuizId { get; set; }

    [Required]
    public Quiz Quiz { get; set; }

    public decimal Correct { get; set; }

    public decimal Incorrect { get; set; }

    public decimal Unattempted { get; set; }
}

三个属性 CorrectIncorrectUnattempted 描述了为响应分配多少点。这些点也可以是负面的。评分策略适用于测验中的所有问题,因此每个测验只能有一个ScoreStrategy。 我有两个子classes:

public class DifficultyScoreStrategy : ScoreStrategy
{  
    public QuestionDifficulty Difficulty { get; set; }
}

public class QuestionScoreStrategy : ScoreStrategy
{ 
     [Required]
     public Question Question { get; set; }
}

我的问题有三个难度级别(EasyMediumHardQuestionDifficulty 是一个枚举)。 DifficultyScoreStrategy 指定是否需要为特定难度的问题分配不同的分数。这将覆盖适用于整个测验的基础 ScoreStrategy。每个难度级别可以有一个实例。

第三,我有一个 QuestionScoreStrategy class 指定特定问题的分数是否必须以不同方式授予。这会覆盖测验范围 ScoreStrategy 和难度范围 DifficultyStrategy。每个问题可以有一个实例。

在评估测验的回答时,我想实施逐级回退机制:

每个问题:

options => {
    options.UseFallbackStrategy(
        correct: 1, 
        incorrect: 0, 
        unattempted: 0
    );
} 

).

总结

我已经在 table 中总结了以上信息:

Strategy Type Priority Maximum instances per quiz
QuestionScoreStrategy 1st (highest) As many as there are questions in the quiz
DifficultyScoreStrategy 2nd 4, one for each difficulty level
ScoreStrategy 3rd Only one
Fallback strategy
(Default { Correct = 1, Incorrect = 0, Unattempted = 0})
4th (lowest) Configured for the entire app. Shared by all quizzes

我有一个名为 EvaluationStrategy 的容器 class,其中包含这些评分策略以及其他评估信息:

partial class EvaluationStrategy
{
    public int Id { get; set; }

    public int QuizId { get; set; }

    public decimal MaxScore { get; set; }

    public decimal PassingScore { get; get; }

    public IEnumerable<ScoreStrategy> ScoreStrategies { get; set; }
}

我尝试过的:

我在上面的 EvaluationStrategy class 中添加了一个名为 GetStrategyByQuestion() 的方法(注意它被声明为 partial),它实现了这个回退行为,也是一个依次调用此方法的伴随索引器。我已经声明了两个 HashSet 类型 DifficultyScoreStrategyQuestionScoreStrategy 并且 Initialize() 方法实例化了它们。然后将所有的评分策略按类型切换并添加到相应的HashSet中,每题只能有一个ScoreStrategy,存储在defaultStrategy:

partial class EvaluationStrategy
{
    private ScoreStrategy FallbackStrategy = new() { Correct = 1, Incorrect = 0, Unattempted = 0 }; 
    private ScoreStrategy defaultStrategy;
    HashSet<DifficultyScoreStrategy> dStrategies;
    HashSet<QuestionScoreStrategy> qStrategies;


    public void Initialize()
    {
        qStrategies = new();
        dStrategies = new();
        // Group strategies by type
        foreach (var strategy in strategies)
        {
            switch (strategy)
            {
                case QuestionScoreStrategy qs: qStrategies.Add(qs); break;
                case DifficultyScoreStrategy ds: dStrategies.Add(ds); break;
                case ScoreStrategy s: defaultStrategy = s; break;
            }
        }
    }

    public ScoreStrategy this[Question question] => GetStrategyByQuestion(question);
    

    public ScoreStrategy GetStrategyByQuestion(Question question)
    {
        if (qStrategies is null || dStrategies is null)
        {
            Initialize();
        }
        // Check if question strategy exists
        if (qStrategies.FirstOrDefault(str => str.Question.Id == question.Id) is not null and var qs)
        {
            return qs;
        }
        // Check if difficulty strategy exists
        if (dStrategies.FirstOrDefault(str => str.Question.Difficulty == question.Difficulty) is not null and var ds)
        {
            return ds;
        }
        // Check if default strategy exists
        if (defaultStrategy is not null)
        {
            return defaultStrategy;
        }
        // Fallback
        return FallbackStrategy;
    }
}

这个方法似乎有点笨拙,我觉得不太合适。使用部分 class 并添加到 EvalutationStrategy 似乎也不正确。如何实现这种逐级回退行为?有没有设计pattern/principle我可以在这里使用?如果未配置,我知道 .NET Framework 中的很多事情都会回退到默认约定。我需要类似的东西。或者有人可以简单地推荐一个更清洁、更优雅、可维护性更好的解决方案吗?


NOTE/ADDITIONAL 信息: 所有测验的 ScoreStrategyEvaluationStrategy 都存储在由带有 TPH 映射的 EF Core(.NET 5):

modelBuilder.Entity<ScoreStrategy>()
                .ToTable("ScoreStrategy")
                .HasDiscriminator<int>("StrategyType")
                .HasValue<ScoreStrategy>(0)
                .HasValue<DifficultyScoreStrategy>(1)
                .HasValue<QuestionScoreStrategy>(2)
                ;
modelBuilder.Entity<EvaluationStrategy>().ToTable("EvaluationStrategy");

我有一个碱基 DbSet<ScoreStrategy> ScoreStrategies 和另一个 DbSet<EvaluationStrategy> EvaluationStrategies。由于 EvaluationStrategy 是一个 EF Core class,我对向其添加逻辑 (GetStrategyByQuestion()) 也有点怀疑。

您可以根据优先级对 ScoringMethods 的顺序进行排序。

首先你按 str is QuestionScoreStrategystr.Question.Id == question.Id 排序。

然后你按str is DifficultyScoreStrategystr.Question.Difficulty == question.Difficulty排序。

(请注意,由于 false 出现在 true 之前,您必须反转条件)

那你就可以FirstOrDefault() ?? defaultStrategy.

示例:

var defaultStrategy = new() { Correct = 1, Incorrect = 0, Unattempted = 0 };

var selectedStrategy = Strategies.OrderBy(str => 
    !(str is QuestionScoreStrategy questionStrat && questionStrat.Question.Id == question.Id)
).ThenBy(str =>
    !(str is DifficultyScoreStrategy difficultySrat && difficultySrat.Difficulty == question.Difficulty)
).FirstOrDefault() ?? defaultStrategy;

您可以通过添加更多 ThenBy 子句轻松地为此添加更多“级别”。

与波莉

有一个名为 Polly which defines a policy called Fallback 的第 3 方库。

使用这种方法,您可以像这样轻松定义后备链:

public ScoreStrategy GetStrategyByQuestionWithPolly(Question question)
{
    Func<ScoreStrategy, bool> notFound = strategy => strategy is null;

    var lastFallback = Policy<ScoreStrategy>
        .HandleResult(notFound)
        .Fallback(FallbackStrategy);

    var defaultFallback = Policy<ScoreStrategy>
        .HandleResult(notFound)
        .Fallback(defaultStrategy);

    var difficultyFallback = Policy<ScoreStrategy>
        .HandleResult(notFound)
        .Fallback(() => GetApplicableDifficultyScoreStrategy(question));

    var fallbackChain = Policy.Wrap(lastFallback, defaultFallback, difficultyFallback);
    fallbackChain.Execute(() => GetApplicableQuestionScoreStrategy(question));
}

我已经提取了 QuestionScoreStrategyDifficultyScoreStrategy 的策略选择逻辑,如下所示:

private ScoreStrategy GetApplicableQuestionScoreStrategy(Question question)
    => qStrategies.FirstOrDefault(str => str.Question.Id == question.Id);

private ScoreStrategy GetApplicableDifficultyScoreStrategy(Question question)
    => dStrategies.FirstOrDefault(str => str.Difficulty == question.Difficulty);

优点

  • 有一个 return 语句
  • 策略声明与链接分开
  • 每个回退都可以由不同的条件触发
  • 主要选择逻辑与备用逻辑分离

缺点

  • 代码真的很重复
  • 您不能使用流利的 API
  • 创建回退链
  • 您需要使用第 3 方库

没有波莉

如果您不想使用第 3 方库来定义和使用回退链,您可以这样做:

public ScoreStrategy GetStrategyBasedOnQuestion(Question question)
{
    var fallbackChain = new List<Func<ScoreStrategy>>
    {
        () => GetApplicableQuestionScoreStrategy(question),
        () => GetApplicableDifficultyScoreStrategy(question),
        () => defaultStrategy,
        () => FallbackStrategy
    };

    ScoreStrategy selectedStrategy = null;
    foreach (var strategySelector in fallbackChain)
    {
        selectedStrategy = strategySelector();
        if (selectedStrategy is not null)
            break;
    }

    return selectedStrategy;
}

优点

  • 有一个 return 语句
  • 回退链的声明和求值是分开的
  • 简洁明了

缺点

  • 不太灵活:每个回退选择都由相同的条件触发
  • 主要选择不与后备分开

我想象所有数据(问题、策略、测验都存储在数据库中)。然后我会期待这样的方式来获得每个策略:

题攻略

var questionStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.QuesionId == question.Id);

难度攻略:

var difficultyStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.Difficulty == question.Difficulty);

测验的默认策略:

var quizStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.QuizId == question.QuizId)

基于此以及您已经提供的内容,策略只有三个数字:正确答案得分,错误和未尝试答案得分。

因此,这成为抽象 class 的完美候选者,它将为三个实体的基础 class 服务 - 三种类型的策略 - 这将是三个表,因为每个表都有不同的关系:

public abstract class ScoreStrategy
{
    public double Correct { get; set; }
    public double Incorrect { get; set; }
    public double Unattempted { get; set; }
}
// Table with FK relation to Questions table
public class QuestionScoreStrategy : ScoreStrategy
{
    public Question { get; set; }
    public int QuestionId { get; set; }
}
// If you have table with difficulties, there should be FK relation to it.
// If you do not have table - it's worth consideration, you could then 
// easily add more difficulties.
public class DifficultyStrategy : ScoreStrategy
{
    public QuestionDifficulty Difficulty { get; set; }
}
// FK relation to Quizes table
public class QuizScoreStrategy : ScoreStrategy
{
    public Quiz { get; set; }
    public int QuizId { get; set; }
}

通过这种方式,您最终会得到仅存储相关数据的粒度良好的表。

那么,用法将变为:

// Ideally, this method should be in some repoistory (look at repository design pattern) in data access layer
// and should leverage usage of async / await as well.
public ScoreStrategy GetScoreStrategy(Question question)
{
    return dbContext.QuestionScoreStrategies.SingleOrDefault(qs => qs.QuestionId == question.Id)
        ?? dbContext.DifficultyStrategies.SingleOrDefault(ds => ds.Difficulty == question.Difficulty)
        ?? dbContext.QuizScoreStrategies.SingleOrDefault(qs => qs.QuizId == question.QuizId);
}

那么你可以这样使用这个方法:

// This should be outside data access layer. Here you perform logic of getting question.
// This could be some ScoringManager class which should be singleton (one instance only).
// Then you could define fallback in private fields:
private readonly double FALLBACK_CORRECT_SCORE;
private readonly double FALLBACK_INCORRECT_SCORE;
private readonly double FALLBACK_UNATTEMPTED_SCORE;
// private constructor, as this should be singleton
private ScoringManager(double correctScore, double incorrectScore, double unattemptedScore)
    => (FALLBACK_CORRECT_SCORE, FALLBACK_INCORRECT_SCORE, FALLBACK_UNATTEMPTED_SCORE) =
       (correctScore, incorrectScore, unattemptedScore);
 
public double CalcScoreForQuestion(Question question)
{
    var scoreStrategy = GetScoreStrategy(question);
    if (question answered correctly) 
        return scoreStrategy?.Correct ?? FALLBACK_CORRECT_SCORE;
    if (question answered incorrectly) 
        return scoreStrategy?.Incorrect ?? FALLBACK_INCORRECT_SCORE;
    if (question unattempted) 
        return scoreStrategy?.Unattempted ?? FALLBACK_UNATTEMPTED_SCORE;
}

注意

这只是我组织事物的草稿,很可能在编写代码时我会提出改进,但我认为这是前进的方向。例如 ScoringManager 可以有 ConfigureFallbackScore 方法,这将允许动态更改回退分数(这需要使各个字段不是 readonly)。

更新

定义回退策略,以便定义枚举:

public enum FallbackLevel
{
    Difficulty,
    Question,
    Quiz,
}

然后评分管理器可以有配置策略的方法(连同支持字段):

private FallbackLevel _highPrecedence;
private FallbackLevel _mediumPrecedence;
private FallbackLevel _lowPrecedence;

public void ConfigureFallbackStrategy(FallbackLevel highPrecedence, FallbackLevel mediumPrecedence, FallbackLevel lowPrecedence)
{
    _highPrecedence = highPrecedence;
    _mediumPrecedence = mediumPrecedence;
    _lowPrecedence = lowPrecedence;
}

然后在manager中写获取策略逻辑:

public ScoreStrategy GetScoreStrategy(Question question)
{
   var scoreStrategy = GetScoreStrategy(_highPrecedence, question)
       ?? GetScoreStrategy(_mediumPrecedence, question)
       ?? GetScoreStrategy(_lowPrecedence, question);
}

private ScoreStrategy GetScoreStrategy(FallbackLevel lvl, Question question) => lvl switch
{
    FallbackLevel.Difficulty => dbContext.DifficultyStrategies.SingleOrDefault(ds => ds.Difficulty == question.Difficulty),
    FallbackLevel.Question => dbContext.QuestionScoreStrategies.SingleOrDefault(qs => qs.QuestionId == question.Id),
    FallbackLevel.Quiz => dbContext.QuizScoreStrategies.SingleOrDefault(qs => qs.QuizId == question.QuizId),
}

这样可以非常容易地以任何方式配置回退策略。当然,还有一些注意事项:

  • 确保所有回退策略都是唯一的,例如高、中、低策略不可能相同,
  • 数据库上下文只能通过存储库模式访问
  • 添加更多完整性检查(如空值等)

我省略了那些部分,因为我专注于纯粹的功能。