Bonnes pratiques de développement
Les bonnes pratiques sont essentielles pour maintenir la qualité du code et la productivité de l'équipe. Cet article couvre Logging structuré avec Serilog et les standards de développement incontournables.
Qualité du code
Principes SOLID
// S - Single Responsibility Principle
// Mauvais exemple
public class UserService
{
public void CreateUser(User user) { /* ... */ }
public void SendEmail(string email, string message) { /* ... */ }
public void LogActivity(string activity) { /* ... */ }
}
// Bon exemple - responsabilités séparées
public class UserService
{
private readonly IUserRepository _userRepository;
private readonly IEmailService _emailService;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository userRepository, IEmailService emailService, ILogger<UserService> logger)
{
_userRepository = userRepository;
_emailService = emailService;
_logger = logger;
}
public async Task<Result> CreateUserAsync(CreateUserRequest request)
{
try
{
var user = new User(request.Email, request.Name);
await _userRepository.AddAsync(user);
await _emailService.SendWelcomeEmailAsync(user.Email);
_logger.LogInformation("User created: {UserId}", user.Id);
return Result.Success();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating user");
return Result.Failure("User creation failed");
}
}
}
// O - Open/Closed Principle
public abstract class PaymentProcessor
{
public abstract Task<PaymentResult> ProcessAsync(PaymentRequest request);
protected PaymentResult CreateSuccessResult(string transactionId)
{
return new PaymentResult { Success = true, TransactionId = transactionId };
}
}
public class CreditCardProcessor : PaymentProcessor
{
public override async Task<PaymentResult> ProcessAsync(PaymentRequest request)
{
// Logique spécifique aux cartes de crédit
var transactionId = await ProcessCreditCardAsync(request);
return CreateSuccessResult(transactionId);
}
}
public class PayPalProcessor : PaymentProcessor
{
public override async Task<PaymentResult> ProcessAsync(PaymentRequest request)
{
// Logique spécifique à PayPal
var transactionId = await ProcessPayPalAsync(request);
return CreateSuccessResult(transactionId);
}
}
Gestion des erreurs robuste
// Pattern Result pour éviter les exceptions
public class Result<T>
{
public bool IsSuccess { get; private set; }
public T? Value { get; private set; }
public string? Error { get; private set; }
public Exception? Exception { get; private set; }
private Result(T value)
{
IsSuccess = true;
Value = value;
}
private Result(string error, Exception? exception = null)
{
IsSuccess = false;
Error = error;
Exception = exception;
}
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error, Exception? exception = null) => new(error, exception);
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure)
{
return IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
}
// Service avec gestion d'erreurs
public class ArticleService
{
private readonly IArticleRepository _repository;
private readonly ILogger<ArticleService> _logger;
public ArticleService(IArticleRepository repository, ILogger<ArticleService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Result<ArticleDto>> GetArticleAsync(int id)
{
try
{
if (id <= 0)
return Result<ArticleDto>.Failure("Invalid article ID");
var article = await _repository.GetByIdAsync(id);
if (article == null)
return Result<ArticleDto>.Failure("Article not found");
var dto = new ArticleDto
{
Id = article.Id,
Title = article.Title,
Content = article.Content
};
return Result<ArticleDto>.Success(dto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving article {ArticleId}", id);
return Result<ArticleDto>.Failure("An error occurred while retrieving the article", ex);
}
}
}
// Utilisation dans un contrôleur
[ApiController]
[Route("api/[controller]")]
public class ArticleController : ControllerBase
{
private readonly ArticleService _articleService;
public ArticleController(ArticleService articleService)
{
_articleService = articleService;
}
[HttpGet("{id}")]
public async Task<ActionResult<ArticleDto>> GetArticle(int id)
{
var result = await _articleService.GetArticleAsync(id);
return result.Match<ActionResult<ArticleDto>>(
onSuccess: article => Ok(article),
onFailure: error => error.Contains("not found") ? NotFound(error) : BadRequest(error));
}
}
Tests automatisés
Tests unitaires avec mocks
[TestFixture]
public class ArticleServiceTests
{
private Mock<IArticleRepository> _mockRepository;
private Mock<ILogger<ArticleService>> _mockLogger;
private ArticleService _service;
[SetUp]
public void Setup()
{
_mockRepository = new Mock<IArticleRepository>();
_mockLogger = new Mock<ILogger<ArticleService>>();
_service = new ArticleService(_mockRepository.Object, _mockLogger.Object);
}
[Test]
public async Task GetArticleAsync_WithValidId_ReturnsArticle()
{
// Arrange
var articleId = 1;
var expectedArticle = new Article
{
Id = articleId,
Title = "Test Article",
Content = "Test Content"
};
_mockRepository.Setup(r => r.GetByIdAsync(articleId))
.ReturnsAsync(expectedArticle);
// Act
var result = await _service.GetArticleAsync(articleId);
// Assert
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual(expectedArticle.Title, result.Value.Title);
Assert.AreEqual(expectedArticle.Content, result.Value.Content);
_mockRepository.Verify(r => r.GetByIdAsync(articleId), Times.Once);
}
[Test]
public async Task GetArticleAsync_WithInvalidId_ReturnsFailure()
{
// Arrange
var invalidId = -1;
// Act
var result = await _service.GetArticleAsync(invalidId);
// Assert
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("Invalid article ID", result.Error);
_mockRepository.Verify(r => r.GetByIdAsync(It.IsAny<int>()), Times.Never);
}
[Test]
public async Task GetArticleAsync_WithRepositoryException_ReturnsFailure()
{
// Arrange
var articleId = 1;
var exception = new Exception("Database error");
_mockRepository.Setup(r => r.GetByIdAsync(articleId))
.ThrowsAsync(exception);
// Act
var result = await _service.GetArticleAsync(articleId);
// Assert
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("An error occurred while retrieving the article", result.Error);
Assert.AreEqual(exception, result.Exception);
// Vérifier que l'erreur a été loggée
_mockLogger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Error retrieving article")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}
Tests d'intégration
[TestFixture]
public class ArticleIntegrationTests
{
private WebApplicationFactory<Program> _factory;
private HttpClient _client;
private ApplicationDbContext _context;
[SetUp]
public void Setup()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remplacer la base de données par une base en mémoire
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
});
_client = _factory.CreateClient();
using var scope = _factory.Services.CreateScope();
_context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
_context.Database.EnsureCreated();
}
[TearDown]
public void TearDown()
{
_context?.Dispose();
_client?.Dispose();
_factory?.Dispose();
}
[Test]
public async Task GetArticle_WithExistingId_ReturnsArticle()
{
// Arrange
var article = new Article
{
Title = "Integration Test Article",
Content = "Test content for integration test"
};
_context.Articles.Add(article);
await _context.SaveChangesAsync();
// Act
var response = await _client.GetAsync($"/api/articles/{article.Id}");
// Assert
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<ArticleDto>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
Assert.IsNotNull(result);
Assert.AreEqual(article.Title, result.Title);
Assert.AreEqual(article.Content, result.Content);
}
}
Configuration et déploiement
Configuration par environnement
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=BlogMagasine;Trusted_Connection=true;"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
// appsettings.Production.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod-server;Database=BlogMagasine;User Id=sa;Password={DB_PASSWORD};"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"System": "Error",
"Microsoft": "Error"
}
}
}
// Configuration des options fortement typées
public class DatabaseOptions
{
public const string SectionName = "Database";
public string ConnectionString { get; set; } = string.Empty;
public int CommandTimeout { get; set; } = 30;
public bool EnableSensitiveDataLogging { get; set; } = false;
}
// Enregistrement des options
builder.Services.Configure<DatabaseOptions>(
builder.Configuration.GetSection(DatabaseOptions.SectionName));
Pipeline CI/CD avec GitHub Actions
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Publish
run: dotnet publish -c Release -o ./publish
- name: Deploy to Azure
uses: azure/webapps-deploy@v2
with:
app-name: 'blog-magazine-app'
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: ./publish
Monitoring et observabilité
// Configuration d'Application Insights
builder.Services.AddApplicationInsightsTelemetry();
// Middleware de monitoring personnalisé
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"HTTP {Method} {Path} responded {StatusCode} in {Duration}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
}
// Health checks détaillés
builder.Services.AddHealthChecks()
.AddDbContext<ApplicationDbContext>("database")
.AddRedis(builder.Configuration.GetConnectionString("Redis"), "redis")
.AddUrlGroup(new Uri("https://api.external-service.com/health"), "external-api");
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
Conclusion
L'adoption de bonnes pratiques avec Logging structuré avec Serilog est essentielle pour maintenir la qualité et l'évolutivité de vos applications. Les principes SOLID, les tests automatisés, la configuration appropriée et le monitoring constituent les piliers d'un développement professionnel et durable.