Wprowadzenie
W dzisiejszym wpisie pokażę dwa sposoby, dzięki którym można integrować bibliotekę Fluent Validation z ASP.NET MVC. Pierwszy domyślny, który dostarcza sama biblioteka, oraz drugi mój, który rozwiązuje część problemów z domyślnej integracji.
Domyślna integracja Fluent Validation z ASP.NET MVC
Integracja Fluent Validation z ASP.NET MVC jest bardzo prosta i sprowadza się do dwóch kroków:
- Instalacja pakietu FluentValidation.Mvc5 z nugeta
- Dodanie linijki konfiguracji biblioteki w metodzie startującej aplikację (MvcApplication.Application_Start w pliku Global.asax.cs):
FluentValidationModelValidatorProvider.Configure(); |
Mając już zintegrowaną bibliotekę, wystarczy dla klasy (prawdopodobnie będzie to jakiś ViewModel przekazywany jako parametr do akcji POST) dodać walidator i udekorować ją atrybutem Validator, przekazując typ walidatora – tak, jak to jest zrobione poniżej:
[Validator(typeof(CreateUserViewModelValidator))] | |
public class CreateUserViewModel | |
{ | |
public string Email { get; set; } | |
public bool CreateInvoice { get; set; } | |
public string Nip { get; set; } | |
} |
public class CreateUserViewModelValidator : AbstractValidator<CreateUserViewModel> | |
{ | |
public CreateUserViewModelValidator() | |
{ | |
RuleFor(u => u.Email) | |
.Cascade(CascadeMode.StopOnFirstFailure) | |
.NotEmpty() | |
.EmailAddress(); | |
RuleFor(u => u.Nip) | |
.NotEmpty() | |
.When(u => u.CreateInvoice); | |
} | |
} |
Fluent Validation integruje się z mechanizmem walidacji ASP.NET MVC, dlatego kod po stronie kontrolera jest identyczny jak w przypadku walidacji z wykorzystaniem data annotations (ma przy tym dużo większe możliwości). Poniżej przykład akcji:
[HttpGet] | |
public ActionResult ViewModel() | |
{ | |
return View(new CreateUserViewModel()); | |
} | |
[HttpPost] | |
public ActionResult ViewModel(CreateUserViewModel viewModel) | |
{ | |
if(ModelState.IsValid == false) | |
{ | |
return View(viewModel); | |
} | |
return Content("Dodano"); | |
} |
W wyniku powyższego kodu (oraz widoku create wygenerowanego przez Visual Studio na podstawie view modelu) otrzymamy taki widok przy błędnej walidacji:
Takie podejście jest fajne w momencie, gdy przychodzimy z data annotations na Fluent Validation. Wystarczy wtedy przenieść logikę walidacji z atrybutów i ewentualnie kontrolera do walidatora oraz zostawić większość kodu w samym kontrolerze.
Co fajne, podobnie jak data annotations, Fluent Validation dla podstawowych walidatorów dodaje sprawdzanie danych po stronie javascript.
Niestety takie podejście ma też wady, które skłoniły mnie do poszukania własnego podejścia do integracji. Przede wszystkim walidacja oparta na view modelach powoduje, że bardzo często mamy tę samą logikę biznesową walidacji w różnych walidatorach. O ile dodanie obiektu oraz edycję można zrobić na jednym view modelu, to już edycję (np. użytkownika) w różnych kontrolerach (np. użytkownik edytuje sam swój profil, a drugą akcję wykonuje administrator) realizujemy na ogół na dwóch różnych view modelach zawierających różne pola. W takim scenariuszu potrzebujemy dwóch walidatorów, które zawierają właściwie taką samą logikę.
Dodatkowo bardzo często w aplikacji mamy kilka różnych końcówek (np. widoki html dla użytkownika korzystającego z przeglądarki oraz web api dla aplikacji mobilnych). W takiej sytuacji również walidacja na poziomie view modeli powoduje, że musimy kopiować reguły walidacji.
Własna integracja Fluent Validation z ASP.NET MVC
Aby rozwiązać problemy domyślnej integracji, przygotowałem własną, która rozwiązuje powyższe problemy, ale niestety też nie jest bez wad. Na szczęście wady są dużo mniejsze niż zalety.
Przede wszystkim wychodzę z założenia, że walidacja jest elementem logiki biznesowej aplikacji, dlatego powinna być na jej poziomie, a nie na poziomie obsługi żądania.
W moich aplikacjach wszystkie metody biznesowe (i nie tylko one) zwracają obiekt Result, który może wyglądać mniej więcej tak (w realnych aplikacjach jest nieco bardziej rozbudowany, np. ma możliwość przekazywania wyjątków):
public class Result | |
{ | |
public bool Success { get; set; } | |
public IEnumerable<ErrorMessage> Errors { get; set; } | |
public static Result<T> Ok<T>(T value) | |
{ | |
return new Result<T>() | |
{ | |
Success = true, | |
Value = value | |
}; | |
} | |
public static Result<T> Failure<T>(IEnumerable<ValidationFailure> validationFailures) | |
{ | |
var result = new Result<T>(); | |
result.Success = false; | |
result.Errors = validationFailures.Select(v => new ErrorMessage() | |
{ | |
PropertyName = v.PropertyName, | |
Message = v.ErrorMessage | |
}); | |
return result; | |
} | |
} | |
public class Result<T> : Result | |
{ | |
public T Value { get; set; } | |
} | |
public class ErrorMessage | |
{ | |
public string PropertyName { get; set; } | |
public string Message { get; set; } | |
} |
Result ma informację o tym, czy operacja wykonała się poprawnie, czy nie, oraz w przypadku błędów walidacji w kolekcji Errors znajduje się informacja o nich.
Przy takim podejściu walidację robimy na podstawie obiektu domenowego, w przykładzie klasy User:
public class User | |
{ | |
public string Email { get; set; } | |
public bool CreateInvoice { get; set; } | |
public string Nip { get; set; } | |
} |
public class UserValidator : AbstractValidator<User> | |
{ | |
public UserValidator() | |
{ | |
RuleFor(u => u.Email) | |
.Cascade(CascadeMode.StopOnFirstFailure) | |
.NotEmpty() | |
.EmailAddress(); | |
RuleFor(u => u.Nip) | |
.NotEmpty() | |
.When(u => u.CreateInvoice); | |
} | |
} |
Następnie sama walidacja przebiega „ręcznie” w metodzie w logice biznesowej, gdzie zwracany jest właśnie obiekt Result (w tym przypadku wersja generyczna zwracająca zapisanego użytkownika):
public class UsersLogic | |
{ | |
protected UserValidator Validator { get; set; } | |
public UsersLogic() | |
{ | |
Validator = new UserValidator(); | |
} | |
public Result<User> Create(User user) | |
{ | |
var validationResult = Validator.Validate(user); | |
if(validationResult.IsValid == false) | |
{ | |
return Result.Failure<User>(validationResult.Errors); | |
} | |
//zapis danych do bazy | |
return Result.Ok<User>(user); | |
} | |
} |
Użycie własnej implementacji
Za pomocą Extensions Method opakowuję następnie kawałek kodu, który przekopiowuje informacje o błędach do ModelState, aby już samo ASP.NET MVC wyświetliło informacje o błędach w widoku. Wykorzystuję Extensions Method w projekcie webowym, ponieważ Result jest zdefiniowany na poziomie logiki biznesowej, w której nie chcę referować do ASP.NET MVC. Kod jest dość prosty:
public static class ResultExtensions | |
{ | |
public static void AddErrorToModelState(this Result result, ModelStateDictionary modelState) | |
{ | |
if (result.Success) | |
{ | |
return; | |
} | |
foreach (var error in result.Errors) | |
{ | |
modelState.AddModelError(error.PropertyName, error.Message); | |
} | |
} | |
} |
Następnie pozostaje użyć logiki biznesowej w kontrolerze i po sprawdzeniu wyniku operacji skopiować błędy do ModelState:
[HttpGet] | |
public ActionResult Model() | |
{ | |
return View(new CreateWithModelUserViewModel()); | |
} | |
[HttpPost] | |
public ActionResult Model(CreateWithModelUserViewModel viewModel) | |
{ | |
if(ModelState.IsValid == false) | |
{ | |
return View(viewModel); | |
} | |
var user = new User() //I normally use the automapper to map model <-> viewmodel | |
{ | |
Email = viewModel.Email, | |
CreateInvoice = viewModel.CreateInvoice, | |
Nip = viewModel.Nip | |
}; | |
var result = UsersLogic.Create(user); | |
if(result.Success == false) | |
{ | |
result.AddErrorToModelState(ModelState); | |
return View(viewModel); | |
} | |
return Content("Dodano"); | |
} |
Jak widać na powyższym kodzie, w akcji dla POST w pierwszej kolejności sprawdzam również ModelState. To sprawdzenie robię na potrzeby weryfikacji poprawności wpisanych danych dla ich typów. Gdy na przykład oczekujemy w polu inta, a użytkownik wpisze coś, co się nie parsuje, zostanie to wyłapane przez tę linijkę. Gdyby tak nie było, wtedy właściwość w view modelu będzie miała wartość domyślną (w tym przypadku 0), która może oznaczać wartość poprawną.
W drugim kroku mapujemy view model na model. W normalnej sytuacji używam do tego automappera, ale w przykładzie zrobiłem to ręcznie, aby za bardzo tego przykładu nie rozbudowywać. Następnie wywołujemy metodę logiki biznesowej i sprawdzamy, czy operacja się udała. Jeśli nie, za pomocą wcześniej dodanej metody przerzucamy błędy z obiektu wyniku do ModelState i wyświetlamy widok ponownie.
Wynik samego działa aplikacji jest taki sam, jak w domyślnej integracji:
Podsumowanie
Jak widać wyżej, taka integracja powoduje, że możemy współdzielić logikę walidacji między różnymi akcjami w aplikacji czy nawet między różnymi punktami dostępowymi – w ASP.NET MVC oraz ASP.NET WEB API używamy tych samych metod logiki biznesowej.
Niestety to rozwiązanie nie jest idealne. W kodzie kontrolerów musimy za każdym razem napisać kilka linijek więcej kodu, a po stronie javascript w tym podejściu nie mamy walidacji dla podstawowych walidatorów. W drugim przypadku zauważyłem, że na ogół dodajemy ręcznie walidację w javascript, więc oba te minusy w moim przypadku są dużo mniejsze niż konieczność powielania reguł walidacji dla różnych akcji.
Kod przykładu jest dostępny na githubie.
1 thought on “Integracja Fluent Validation z ASP.NET MVC”