首页 > 解决方案 > 使用 Hibernate 验证器验证数据库完整性

问题描述

Hibernate 验证器对我来说非常适合验证 hibernate 获取的对象,但问题是我想确保在持久化/更新数据库中的对象后满足某些条件。例如:

我的条件是:用户最多可以托管 3 个游戏

约束注解:

@Target({ FIELD, TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = GamesCountValidator.class)
@Documented
public @interface ConstrainHostGamesCount {
    String message() default "{com.afrixs.mygameserver.db.constraint.ConstrainHostGamesCount.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

验证器:

public class GamesCountValidator implements ConstraintValidator<ConstrainHostGamesCount, User> {
    @Override
    public void initialize(ConstrainHostGamesCount constraintAnnotation) {
    }

    @Override
    public boolean isValid(User user, ConstraintValidatorContext context) {
        if (user == null)
            return true;
        
        return user.getGames().size() <= 3;
    }
}

用户等级:

@Entity
@Table(name="Users")
@ConstrainHostGamesCount
public class User {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    @Column(name="id", nullable=false, unique=true, length=11)
    private int id;

    @Column(name="name", length=30, unique=true)
    private String name;
    
    @OneToMany(mappedBy = "user", orphanRemoval = true, fetch = FetchType.LAZY)
    private Set<Game> games = new HashSet<>();

    //generic getters and setters
}

游戏类:

@Entity
@Table(name="Games")
public class Game {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    @Column(name="id", nullable=false, unique=true, length=11)
    private int id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="user_id")
    @ConstrainHostGamesCount
    private User user;

    //generic getters and setters
}

测试方法:

public class Test {
    public static void hostGames(String userName, int count) {
        try {
            Session session = DatabaseManager.getSessionFactory().getCurrentSession();
            session.beginTransaction();

            Query userQuery = session.createQuery("from User where name = :name");
            userQuery.setParameter("name", name);
            User user = (User)userQuery.uniqueResult();

            for (int i = 0; i < count; i++) {
                Game = new Game();
                game.setUser(user);
                session.persist(game);
            }
            session.getTransaction().commit();
        } catch (Exception e) {
            DatabaseManager.getSessionFactory().getCurrentSession().getTransaction().rollback();
            e.printStackTrace();
        }
    }
}

期望的行为Test.hostGames("afrixs", 4)是失败。然而,验证器在更新之前验证用户对象的状态,即。games.size() 等于 0,因此满足约束条件并且Test.hostGames("afrixs", 4)在第二次调用之前没有任何失败。当然,在这种情况下我们可以手动向用户添加游戏,user.getGames().add(game)但是这种态度很容易出错(游戏需要在代码中的任何地方都以这种方式添加到用户),并且如果例如两个Test.hostGames("afrixs", 2)异步调用它并不能解决问题。

所以我的问题是:使用休眠约束数据库完整性的正确方法是什么?有没有办法让验证器在将对象存储到数据库后检查它们的最终状态?或者我是否需要手动进行约束(比如执行另一个事务session.getTransaction().commit并检查条件,如果不满足则回滚更新事务)?或者我应该忽略休眠并为此使用 SQL 触发器?感谢您的回答,他们将有很大帮助

这是我当前的休眠验证配置:

<property name="javax.persistence.validation.group.pre-persist">javax.validation.groups.Default</property>
<property name="javax.persistence.validation.group.pre-update">javax.validation.groups.Default</property>
<property name="hbm2ddl.auto">validate</property>

标签: javadatabasehibernatevalidation

解决方案


好的,我做了一些实验,写了一个小测试类。为了简单起见,我将约束更改为“用户最多可以托管 1 个游戏”。

public class DBTest {
    @Test
    public void gamesCountConstraintWorking() {
        DBManager.deleteHostedGames("afrixs");
        boolean ok1 = DBManager.createOneGame("afrixs");
        boolean ok2 = DBManager.createOneGame("afrixs");
        int gamesCount = DBManager.deleteHostedGames("afrixs");
        System.out.println("Sync test: count: "+gamesCount+", ok1: "+ok1+", ok2: "+ok2);
        assertTrue(gamesCount <= 1);
        assertTrue(!(ok1 && ok2));
    }

    @Test
    public void gamesCountConstraintWorkingAsync() throws InterruptedException {
        DBManager.deleteHostedGames("afrixs");
        for (int i = 0; i < 30; i++) {
            CreateOneGameRunnable r1 = new CreateOneGameRunnable(1);
            CreateOneGameRunnable r2 = new CreateOneGameRunnable(2);
            Thread t1 = new Thread(r1);
            Thread t2 = new Thread(r2);
            t1.start();
            t2.start();
            int maxCount = 0;
            while (r1.running || r2.running) {
                int count = DBManager.selectHostedGamesCount("afrixs");
                System.out.println("count: "+count);
                maxCount = Math.max(maxCount, count);
            }
            t1.join();
            t2.join();
            int gamesCount = DBManager.deleteHostedGames("afrixs");
            System.out.println("Async test: count: "+gamesCount+", maxCount: "+maxCount+", ok1: "+r1.ok+", ok2: "+r2.ok);
            assertTrue(maxCount <= 1 && gamesCount <= 1);
            assertTrue(!(r1.ok && r2.ok));
        }
    }

    private class CreateOneGameRunnable implements Runnable {
        public boolean ok;
        public boolean running = true;
        private int number;
        CreateOneGameRunnable(int number) {
            this.number = number;
        }
        @Override
        public void run() {
            System.out.println("Starting "+number);
            ok = DBManager.createOneGame("afrixs");
            System.out.println("Finished "+number);
            running = false;
        }
    }
}

首先,我在分配关系时user.getGames().add(game);尝试了@Guillaume 的建议。测试成功了,但是,没有。这意味着这种态度成功地维护了会话一致性(以获取所有用户游戏为代价),但是,数据库完整性没有得到维护。game.setUser(user);gamesCountConstraintWorkinggamesCountConstraintWorkingAsync

对这两个测试都有效的解决方案是(正如@OrangeDog 建议的那样)将约束直接添加到数据库模式中。MySQL:

DELIMITER $$

CREATE TRIGGER check_user_games_count
AFTER INSERT
ON Games FOR EACH ROW
BEGIN
    DECLARE gamesCount INT;
    SET gamesCount = (SELECT COUNT(id) FROM Games WHERE user_id = new.user_id);
    IF gamesCount > 1 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'User may host at most 1 game';
    END IF;
END $$

DELIMITER ;

所以我的总结是,Hibernate 作为数据库之上的一个层可以很好地工作,但是如果你想确保持久化的数据看起来像你想要的那样,你需要直接进入你的数据库模式并在那里执行操作。(但这只是这个实验的结果,也许有人知道使用 Hibernate 的解决方案)

注意:我尝试使用 BEFORE UPDATE 触发器和触发器内的随机延迟进行测试,并且测试也成功,似乎在插入时为表获取了某种锁,所以是的,这是一个安全的解决方案。(注 2:BEFORE UPDATE 触发器为此需要 gamesCount+1 > 1 条件,并且在一个查询中插入多行的情况下,约束可能会失败(未测试))


推荐阅读