This text is devoted to different approaches to data validation: what pitfalls can a project come across and what methods and technologies should be guided by validating data in Java applications.
I have often seen projects whose creators did not bother to choose the approach to data validation at all. The teams worked on the project under incredible pressure in the form of deadlines and vague requirements, and as a result, they simply did not have time for accurate, consistent validation. Therefore, their validation code is scattered everywhere: in Javascript snippets, screen controllers, business logic bins, domain entities, triggers, and database constraints. This code was full of if-else statements, it threw out a bunch of exceptions, and try to figure out where they have this particular piece of data validated ... As a result, as the project progresses, it becomes difficult and expensive to follow the requirements (often quite confused), and uniformity of data validation approaches.
So is there some simple and elegant way to validate data? A way that protects us from the sin of unreadability, a way that gathers all the validation logic together, and which has already been created for us by the developers of popular Java frameworks?
Yes, this method exists.
For us, the developers of the CUBA platform , it is very important that you use the best practices. We believe that the validation code should:
Let's see how this can be implemented using the example of an application written using the CUBA Platform framework. However, since CUBA is built on the basis of Spring and EclipseLink, most of the techniques used here will work on any other Java platform that supports the JPA and Bean Validation specifications.
Perhaps the most common and obvious way to validate data is to use constraints at the database level, for example, the required flag (for fields whose value cannot be empty), string length, unique indices, etc. This method is most suitable for corporate applications, since this type of software is usually strictly focused on data processing. However, even here, developers often make mistakes, setting limits separately for each level of the application. Most often the reason lies in the distribution of responsibilities between developers.
Consider an example that most of us know, some even from our own experience ... If the specification states that there should be 10 characters in the passport number field, it is very likely that this will be checked by everyone: the database architect in DDL, the backend developer in the corresponding Entity and REST services, and finally, the developer of the UI directly on the client side. Then this requirement changes, and the field increases to 15 characters. Devopsy change the values ​​of constraints in the database, but nothing changes for the user, because on the client side the restriction is all the same ...
Any developer knows how to avoid this problem - validation should be centralized! In CUBA, such validation is in JPA annotations to entities. Based on this meta-information, CUBA Studio will generate the correct DDL script and apply the appropriate validators on the client side.
If the annotations change, CUBA will update the DDL scripts and generate the migration scripts, so the next time you deploy the project, the new JPA-based constraints will take effect both in the interface and in the application database.
Despite the simplicity and implementation at the database level, which gives absolute reliability to this method, the scope of JPA annotations is limited to the simplest cases that can be expressed in the DDL standard and do not include database triggers or stored procedures. So, JPA-based constraints can make an entity field unique or mandatory or set a maximum column length. You can even set a unique limit for a column combination using the @UniqueConstraint
annotation. But on this, perhaps, everything.
However, in cases requiring more complex validation logic, such as checking the field for a minimum / maximum value, validating with a regular expression, or performing custom validation peculiar only to your application, the approach known as "Bean Validation" .
Everyone knows that it is good practice to follow standards that have a long life cycle, whose effectiveness has been proven on thousands of projects. Java Bean Validation is an approach documented in JSR 380, 349 and 303 and their applications: Hibernate Validator and Apache BVal .
Although this approach is familiar to many developers, it is often underestimated. This is an easy way to embed data validation even in legacy projects, which allows you to build checks that are understandable, simple, reliable, and as close as possible to business logic.
Using Bean Validation gives the project a lot of advantages:
@NotNull
, @Size
, @Min
, @Max
, @Pattern
, @Email
, @Past
, not quite standard @URL
, @Length
, the most powerful @ScriptAssert
and many others .@ValidPassportNumber
to verify that the passport number matches the format depending on the value of the field country
.When a user submits the entered information, the CUBA Platform (like some other frameworks) starts the Bean Validation automatically, so it instantly gives an error message if validation fails and we do not need to launch the validators of the beans manually.
Let's return to the example with the passport number, but this time we will supplement it with several limitations of the Person entity:
name
field must consist of 2 or more characters and must be valid. (As you can see, regexp is not easy, but "Charles Ogier de Batz de Castelmore Comte d'Artagnan" will be tested, but "R2D2" is not);height
must be in the following interval: 0 < height <= 300
cm;email
field must contain a string that matches the format of a valid email.With all these checks, the Person class will look like this:
@Listeners("passportnumber_PersonEntityListener") @NamePattern("%s|name") @Table(name = "PASSPORTNUMBER_PERSON") @Entity(name = "passportnumber$Person") @ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class}) @FraudDetectionFlag public class Person extends StandardEntity { private static final long serialVersionUID = -9150857881422152651L; @Pattern(message = "Bad formed person name: ${validatedValue}", regexp = "^[AZ][az]*(\\s(([az]{1,3})|(([az]+\\')?[AZ][az]*)))*$") @Length(min = 2) @NotNull @Column(name = "NAME", nullable = false) protected String name; @Email(message = "Email address has invalid format: ${validatedValue}", regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$") @Column(name = "EMAIL", length = 120) protected String email; @DecimalMax(message = "Person height can not exceed 300 centimeters", value = "300") @DecimalMin(message = "Person height should be positive", value = "0", inclusive = false) @Column(name = "HEIGHT") protected BigDecimal height; @NotNull @Column(name = "COUNTRY", nullable = false) protected Integer country; @NotNull @Column(name = "PASSPORT_NUMBER", nullable = false, length = 15) protected String passportNumber; ... }
I suppose the use of such annotations as @NotNull
, @DecimalMin
, @Length
, @Pattern
and the like is quite obvious and does not require comments. Let's take a closer look at the implementation of the @ValidPassportNumber
annotation.
Our new @ValidPassportNumber
verifies that Person#passportNumber
matches the regexp pattern for each country specified by the Person#country
field.
To get started, let's take a look at the documentation (manuals on CUBA or Hibernate will work fine), according to it, we need to mark our class with this new annotation and pass the groups
parameter to it, where UiCrossFieldChecks.class
means that this validation should be launched at the cross-stage validations - after checking all the individual fields, and Default.class
stores the constraint in the default validation group.
The annotation description looks like this:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = ValidPassportNumberValidator.class) public @interface ValidPassportNumber { String message() default "Passport number is not valid"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Here @Target(ElementType.TYPE)
says that the purpose of this runtime annotation is the class, and @Constraint(validatedBy = … )
determines that the validation is performed by the ValidPassportNumberValidator
class that implements the ConstraintValidator<...>
interface. The validation code itself is in the isValid(...)
method, which performs the actual check in a fairly straightforward way:
public class ValidPassportNumberValidator implements ConstraintValidator<ValidPassportNumber, Person> { public void initialize(ValidPassportNumber constraint) { } public boolean isValid(Person person, ConstraintValidatorContext context) { if (person == null) return false; if (person.country == null || person.passportNumber == null) return false; return doPassportNumberFormatCheck(person.getCountry(), person.getPassportNumber()); } private boolean doPassportNumberFormatCheck(CountryCode country, String passportNumber) { ... } }
ValidPassportNumberValidator.java
That's all. With the CUBA Platform, we don’t need to write anything except a line of code that will make our custom validation work and give the user error messages.
Nothing complicated, right?
Now let's check how it all works. Here, CUBA has other nishtyaki: it not only shows the user an error message, but also highlights in red the fields that have not passed the bean validation:
Isn't it an elegant solution? You get an adequate mapping of validation errors in the UI by adding only a couple of Java annotations to the entities of the subject area.
Summarizing the section, let us once again briefly list the advantages of the Bean Validation for entities:
Validator
interface manually);But what to do if you need to set a limit on a method, constructor or REST address for validating data originating from an external system? Or if you need to declaratively check the values ​​of method parameters, without writing a tedious code with a set of if-else conditions in each method being tested?
The answer is simple: Bean Validation also applies to methods!
Sometimes you need to go beyond validating the state of the data model. Many methods can benefit from automatic validation of parameters and return values. This may be necessary not only to check the data going to the REST or SOAP addresses, but also in cases where we want to register the preconditions and postconditions of method calls to make sure that the entered data was checked before the method body was executed, or that the return value is in the expected range, or we, for example, just need to declaratively describe the ranges of values ​​of input parameters to improve the readability of the code.
With the help of bean validation, constraints can be applied to the input parameters and return values ​​of methods and constructors to check the preconditions and postconditions of their calls in any Java class. There are several advantages to this path over traditional methods for validating parameters and return values:
IllegalArgumentException
and the like). You can define constraints declaratively, and make the code more understandable and expressive;@Validated
, then the platform will automatically perform the checks each time the method is called.@Documented
, its preconditions and postconditions will be included in the generated JavaDoc.Using 'contract validation' we get clear, compact and easily supported code.
For an example, let's look at the interface of a REST controller in a CUBA application. The PersonApiService
interface allows you to get a list of people from a database using the getPersons()
method and add a new person using the call addNewPerson(...)
.
And do not forget that bean validation is inherited! In other words, if we annotate a certain class, or field, or method, then the same validation annotation will be applied to all classes that inherit this class or implement this interface.
@Validated public interface PersonApiService { String NAME = "passportnumber_PersonApiService"; @NotNull @Valid @RequiredView("_local") List<Person> getPersons(); void addNewPerson( @NotNull @Length(min = 2, max = 255) @Pattern(message = "Bad formed person name: ${validatedValue}", regexp = "^[AZ][az]*(\\s(([az]{1,3})|(([az]+\\')?[AZ][az]*)))*$") String name, @DecimalMax(message = "Person height can not exceed 300 cm", value = "300") @DecimalMin(message = "Person height should be positive", value = "0", inclusive = false) BigDecimal height, @NotNull CountryCode country, @NotNull String passportNumber ); }
Is this code fragment clear enough?
_ (Except for the @RequiredView(“_local”)
annotation, specific to the CUBA Platform and checking that the Person
object returned contains all the fields from the PASSPORTNUMBER_PERSON
table) ._
The @Valid
specifies that every object in the collection returned by the getPersons()
method must also be validated to meet the constraints of the Person
class.
In the CUBA application, these methods are available at the following addresses:
Open the Postman application and make sure that validation works as it should:
As you may have noticed, in the example above, the passport number is not validated. This is because this field requires cross-validation of the parameters of the addNewPerson
method, since the choice of a regular expression pattern for validating passportNumber
depends on the value of the country
field. This cross-validation is a complete analogue of class-level entity constraints!
Cross validation of parameters is supported by JSR 349 ​​and 380. You can read the hibernate documentation to learn how to implement your own cross validation of class / interface methods.
There is no perfection in the world, so bean validation has its drawbacks and limitations:
Order
object and its sub-objects OrderItem
in the database.The CUBA Platform offers two mechanisms for data validation to commit, which are called entity listeners and transaction listeners . Consider them in more detail.
Entity listeners in CUBA are very similar to PreInsertEvent
, PreUpdateEvent
and PredDeleteEvent
listeners that JPA offers to the developer. Both mechanisms allow you to check entity objects before and after they are stored in the database.
In CUBA it is easy to create and connect an entity listener, for this you need two things:
BeforeDeleteEntityListener<T>
,BeforeInsertEntityListener<T>
,BeforeUpdateEntityListener<T>
@Listeners
annotation to the entity object that you @Listeners
to monitor.And that's all.
Compared to the JPA standard (JSR 338, Section 3.5), the CUBA Platform listener interfaces are typed, so you don’t need to cast the argument of type Object
to an entity type to start working with it. The CUBA platform adds to bound entities or caller EntityManager the ability to load and modify other entities. All of these changes will also trigger the corresponding entity listener.
Also, the CUBA platform supports "soft deletion" , an approach where instead of actually deleting records from the database, they are only marked as deleted and become inaccessible for normal use. So, for soft deletion, the platform calls BeforeDeleteEntityListener
/ AfterDeleteEntityListener
, while standard implementations would call PreUpdate
/ PostUpdate
.
Let's look at an example. Here, the Event listener bean connects to an entity class with just one line of code: the @Listeners
annotation, which takes the name of the listener class:
@Listeners("passportnumber_PersonEntityListener") @NamePattern("%s|name") @Table(name = "PASSPORTNUMBER_PERSON") @Entity(name = "passportnumber$Person") @ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class}) @FraudDetectionFlag public class Person extends StandardEntity { ... }
The listener implementation itself looks like this:
/** * Checks that there are no other persons with the same * passport number and country code * Ignores spaces in the passport number for the check. * So numbers "12 45 768007" and "1245 768007" and "1245768007" * are the same for the validation purposes. */ @Component("passportnumber_PersonEntityListener") public class PersonEntityListener implements BeforeDeleteEntityListener<Person>, BeforeInsertEntityListener<Person>, BeforeUpdateEntityListener<Person> { @Override public void onBeforeDelete(Person person, EntityManager entityManager) { if (!checkPassportIsUnique(person.getPassportNumber(), person.getCountry(), entityManager)) { throw new ValidationException( "Passport and country code combination isn't unique"); } } @Override public void onBeforeInsert(Person person, EntityManager entityManager) { // use entity argument to validate the Person object // entityManager could be used to access database // if you need to check the data // throw ValidationException object if validation check failed if (!checkPassportIsUnique(person.getPassportNumber(), person.getCountry(), entityManager)) throw new ValidationException( "Passport and country code combination isn't unique"); } @Override public void onBeforeUpdate(Person person, EntityManager entityManager) { if (!checkPassportIsUnique(person.getPassportNumber(), person.getCountry(), entityManager)) throw new ValidationException( "Passport and country code combination isn't unique"); } ... }
Entity listeners are a great choice if:
Order
, but also objects related to the entity, for example, OrderItems
objects for the Order
entity;Order
and OrderItem
, and we don’t need to check for changes in other classes of entities during a transaction.CUBA transaction listeners also act in the context of transactions, but, compared to entity listeners, they are invoked for each database transaction.
These gives them super strength:
But their shortcomings also determine this:
So, transaction listeners are a good solution when you need to inspect different types of entities using the same algorithm, for example, checking all data for cyber fraud with a single service that serves all your business objects.
Take a look at a sample that checks whether an entity has an @FraudDetectionFlag
annotation, and, if there is, launches a fraud detector. I repeat: keep in mind that this method is called in the system before committing each transaction of the database , so the code should try to check as few objects as possible.
@Component("passportnumber_ApplicationTransactionListener") public class ApplicationTransactionListener implements BeforeCommitTransactionListener { private Logger log = LoggerFactory.getLogger(ApplicationTransactionListener.class); @Override public void beforeCommit(EntityManager entityManager, Collection<Entity> managedEntities) { for (Entity entity : managedEntities) { if (entity instanceof StandardEntity && !((StandardEntity) entity).isDeleted() && entity.getClass().isAnnotationPresent(FraudDetectionFlag.class) && !fraudDetectorFeedAndFastCheck(entity)) { logFraudDetectionFailure(log, entity); String msg = String.format( "Fraud detection failure in '%s' with id = '%s'", entity.getClass().getSimpleName(), entity.getId()); throw new ValidationException(msg); } } } ... }
ApplicationTransactionListener.java
To turn into a transaction listener, a managed bean must implement the BeforeCommitTransactionListener
interface and the beforeCommit
method. Transaction listeners are automatically linked when the application starts. CUBA registers all classes that implement BeforeCommitTransactionListener
or AfterCompleteTransactionListener
as transaction listeners.
Bean validation (JPA 303, 349, and 980) is an approach that can serve as a reliable basis for 95% of the validation cases found in a corporate project. The main advantage of this approach is that most of the validation logic is concentrated directly in the domain model classes. Therefore, it is easy to find, easy to read and easy to maintain. Spring, CUBA, and many other libraries support these standards and automatically perform validation checks during data acquisition at the UI layer, calling validated methods, or the process of storing data through ORM, so from the developer’s point of view, Bean validation often looks like magic.
Some software developers consider validation at the class level of the subject model as unnatural and too complex, they say that data verification at the UI level is a fairly effective strategy. However, I believe that multiple validation points in UI components and controllers are not the most rational approach. , , , , , listener' .
, , :
PS: , Java, , , .
Source: https://habr.com/ru/post/427543/
All Articles