如何优雅实现测验评分策略的分级回退机制?基于.NET EF Core的优化方案问询
Great question! Your current setup gets the job done, but coupling business logic directly to EF Core entities violates the Single Responsibility Principle, and the lookup logic can be streamlined for better performance and maintainability. Let's walk through a more elegant approach that aligns with .NET conventions and SOLID principles.
Core Issues with Current Implementation
- Entity Responsibility Creep:
EvaluationStrategyis an EF Core entity (data model) but now handles business logic (strategy lookup), making it harder to test and maintain. - Inefficient Lookups: Using
FirstOrDefaulton collections every time you need a strategy adds unnecessary overhead. - Rigid Fallback Configuration: The default strategy is hardcoded, not leveraging .NET's flexible configuration system.
Proposed Solution
We'll split the concerns:
- Keep EF entities as pure data models
- Create a dedicated service to handle strategy lookup (with caching for efficiency)
- Use .NET's
IOptionspattern for configurable fallback behavior
Step 1: Define Configurable Fallback Options
First, create a configuration class for the default strategy, plus an extension method to make setup feel idiomatic to .NET:
public class QuizScoreFallbackOptions { public decimal Correct { get; set; } = 1; public decimal Incorrect { get; set; } = 0; public decimal Unattempted { get; set; } = 0; } // Extension method for fluent configuration public static class QuizScoreFallbackOptionsExtensions { public static void UseFallbackStrategy(this QuizScoreFallbackOptions options, decimal correct, decimal incorrect, decimal unattempted) { options.Correct = correct; options.Incorrect = incorrect; options.Unattempted = unattempted; } }
Register this in your startup/Program.cs (for .NET 5, use Startup.cs):
services.Configure<QuizScoreFallbackOptions>(options => { // Configure directly, or bind from appsettings.json options.UseFallbackStrategy(correct: 1, incorrect: 0, unattempted: 0); });
Step 2: Create a Strategy Resolver Service
This service will handle all strategy lookup logic, with caching to avoid reprocessing strategies for the same EvaluationStrategy multiple times:
public interface IQuizScoreStrategyResolver { ScoreStrategy GetStrategyForQuestion(EvaluationStrategy evaluationStrategy, Question question); } public class QuizScoreStrategyResolver : IQuizScoreStrategyResolver { private readonly IOptions<QuizScoreFallbackOptions> _fallbackOptions; private readonly ConcurrentDictionary<int, StrategyLookupCache> _strategyCache = new(); public QuizScoreStrategyResolver(IOptions<QuizScoreFallbackOptions> fallbackOptions) { _fallbackOptions = fallbackOptions; } public ScoreStrategy GetStrategyForQuestion(EvaluationStrategy evaluationStrategy, Question question) { // Get or build cached strategy mappings for this evaluation strategy var cache = _strategyCache.GetOrAdd(evaluationStrategy.Id, _ => BuildStrategyCache(evaluationStrategy)); // 1. Check question-specific strategy first if (cache.QuestionStrategies.TryGetValue(question.Id, out var questionStrategy)) { return questionStrategy; } // 2. Check difficulty-specific strategy if (cache.DifficultyStrategies.TryGetValue(question.Difficulty, out var difficultyStrategy)) { return difficultyStrategy; } // 3. Check global quiz strategy if (cache.GlobalStrategy != null) { return cache.GlobalStrategy; } // 4. Fall back to configured default var fallback = _fallbackOptions.Value; return new ScoreStrategy { Correct = fallback.Correct, Incorrect = fallback.Incorrect, Unattempted = fallback.Unattempted }; } // Preprocess strategies into fast-lookup dictionaries private StrategyLookupCache BuildStrategyCache(EvaluationStrategy evaluationStrategy) { var cache = new StrategyLookupCache(); foreach (var strategy in evaluationStrategy.ScoreStrategies) { switch (strategy) { case QuestionScoreStrategy qs: cache.QuestionStrategies.TryAdd(qs.Question.Id, qs); break; case DifficultyScoreStrategy ds: cache.DifficultyStrategies.TryAdd(ds.Difficulty, ds); break; case ScoreStrategy s when cache.GlobalStrategy == null: cache.GlobalStrategy = s; break; } } return cache; } // Internal cache class to store grouped strategies private class StrategyLookupCache { public Dictionary<int, QuestionScoreStrategy> QuestionStrategies { get; } = new(); public Dictionary<QuestionDifficulty, DifficultyScoreStrategy> DifficultyStrategies { get; } = new(); public ScoreStrategy GlobalStrategy { get; set; } } }
Register the resolver in your DI container:
services.AddScoped<IQuizScoreStrategyResolver, QuizScoreStrategyResolver>();
Step 3: Simplify the EF Entity
Strip all business logic from EvaluationStrategy to return it to its role as a pure data model:
public class EvaluationStrategy { public int Id { get; set; } public int QuizId { get; set; } public decimal MaxScore { get; set; } public decimal PassingScore { get; set; } public IEnumerable<ScoreStrategy> ScoreStrategies { get; set; } }
Step 4: Usage Example
Inject the resolver wherever you need to calculate scores:
public class QuizScoringService { private readonly IQuizScoreStrategyResolver _strategyResolver; public QuizScoringService(IQuizScoreStrategyResolver strategyResolver) { _strategyResolver = strategyResolver; } public decimal CalculateQuestionScore(EvaluationStrategy evalStrategy, Question question, bool isCorrect, bool isAttempted) { var strategy = _strategyResolver.GetStrategyForQuestion(evalStrategy, question); return isAttempted ? isCorrect ? strategy.Correct : strategy.Incorrect : strategy.Unattempted; } }
Key Benefits
- Single Responsibility: Entities handle data, services handle logic—easier to test and maintain.
- Efficient Lookups: Dictionaries provide O(1) access, and caching avoids reprocessing strategies.
- Idiomatic Configuration: Uses .NET's
IOptionspattern, supporting binding fromappsettings.json, environment variables, etc. - Testability: Mock the resolver or fallback options to test edge cases without hitting the database.
- Extensibility: Adding new strategy types only requires updating the
BuildStrategyCachemethod and cache class—no changes to core lookup logic.
Your existing EF Core TPH mapping remains unchanged, as we're only modifying how we consume the data, not how it's stored.
内容的提问来源于stack exchange,提问作者Amal K

