springaspectjspring-data-jpaspring-aopspring-aspects

Applying custom annotation advice to spring data jpa repository


I am working on a mysql master slave replication. I am using spring data jpa(spring boot).

What I needed is all write operations to go to master server and read-only operations to be equally distributed among multiple read-only slaves.

For that I need to:

Use special JDBC driver: com.mysql.jdbc.ReplicationDriver

Set replication: in the URL:

spring:
    datasource:
        driverClassName: com.mysql.jdbc.ReplicationDriver
        url: jdbc:mysql:replication://127.0.0.1:3306,127.0.0.1:3307/MyForum?user=root&password=password&autoReconnect=true
        test-on-borrow: true
        validation-query: SELECT 1
    database: MYSQL

Auto commit needs to be turned off. (*) Connection needs to be set to read-only.

To ensure JDBC Connection is set to read-only, I created an annotation and a simple AOP interceptor.

Annotation

package com.xyz.forum.replication;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; 

/**
 * Created by Bhupati Patel on 02/11/15.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ReadOnlyConnection {
}

Interceptor

package com.xyz.forum.replication;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.hibernate.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.persistence.EntityManager;

/**
 * Created by Bhupati Patel on 02/11/15.
 */

@Aspect
@Component
public class ConnectionInterceptor {

    private Logger logger;

    public ConnectionInterceptor() {
        logger = LoggerFactory.getLogger(getClass());
        logger.info("ConnectionInterceptor Started");
    }

    @Autowired
    private EntityManager entityManager;

    @Pointcut("@annotation(com.xyz.forum.replication.ReadOnlyConnection)")
    public void inReadOnlyConnection(){}


    @Around("inReadOnlyConnection()")
    public Object proceed(ProceedingJoinPoint pjp) throws Throwable {
        Session session = entityManager.unwrap(Session.class);
        ConnectionReadOnly readOnlyWork = new ConnectionReadOnly();

        try{
            session.doWork(readOnlyWork);
            return pjp.proceed();
        } finally {
            readOnlyWork.switchBack();
        }
    }

}

Following is my spring data repository

package com.xyz.forum.repositories;

import com.xyz.forum.entity.Topic;
import org.springframework.data.repository.Repository;

import java.util.List;

/**
 * Created by Bhupati Patel on 16/04/15.
 */
public interface TopicRepository extends Repository<Topic,Integer>{
    Topic save(Topic topic);
    Topic findByTopicIdAndIsDeletedFalse(Integer topicId);
    List<Topic> findByIsDeletedOrderByTopicOrderAsc(Boolean isDelete);

}

Following is my Manager(Service) class.

package com.xyz.forum.manager;

import com.xyz.forum.domain.entry.impl.TopicEntry;

import com.xyz.forum.domain.exception.impl.AuthException;

import com.xyz.forum.domain.exception.impl.NotFoundException;
import com.xyz.forum.entity.Topic;
import com.xyz.forum.replication.ReadOnlyConnection;
import com.xyz.forum.repositories.TopicRepository;
import com.xyz.forum.utils.converter.TopicConverter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;

/**
 * Created by Bhupati Patel on 16/04/15.
 */
@Repository
public class TopicManager {
    @Autowired
    TopicRepository topicRepository;

    @Transactional
    public TopicEntry save(TopicEntry topicEntry) {
        Topic topic = TopicConverter.fromEntryToEntity(topicEntry);
        return TopicConverter.fromEntityToEntry(topicRepository.save(topic));
    }

    @ReadOnlyConnection
    public TopicEntry get(Integer id) {
        Topic topicFromDb = topicRepository.findByTopicIdAndIsDeletedFalse(id);
        if(topicFromDb == null) {
            throw new NotFoundException("Invalid Id", "Topic Id [" + id + "] doesn't exist ");
        }
        return TopicConverter.fromEntityToEntry(topicFromDb);
    }
}

In the above code @ReadOnlyConnection annotation is specified in manager or service layer. Above pieces of code works fine for me. It is a trivial case where in the service layer I am only reading from slave db and writing into master db.

Having said that my actual requirement is I should be able to use @ReadOnlyConnection in repository level itself because I have quite a few business logic where I do both read/write operation in other classes of service layer.Therefore I can't put @ReadOnlyConnection in service layer.

I should be able to use something like this

public interface TopicRepository extends Repository<Topic,Integer>{
    Topic save(Topic topic);
    @ReadOnlyConnection
    Topic findByTopicIdAndIsDeletedFalse(Integer topicId);
    @ReadOnlyConnection
    List<Topic> findByIsDeletedOrderByTopicOrderAsc(Boolean isDelete);

}

Like spring's @Transactional or @Modifying or @Query annotation. Following is an example of what I am referring.

    public interface AnswerRepository extends Repository<Answer,Integer> {
    @Transactional
    Answer save(Answer answer);

    @Transactional
    @Modifying
    @Query("update Answer ans set ans.isDeleted = 1, ans.deletedBy = :deletedBy, ans.deletedOn = :deletedOn " +
            "where ans.questionId = :questionId and ans.isDeleted = 0")
    void softDeleteBulkAnswers(@Param("deletedBy") String deletedBy, @Param("deletedOn") Date deletedOn,
                               @Param("questionId") Integer questionId);
}

I am novice to aspectj and aop world, I tried quite a few pointcut regex in the ConnectionInterceptor but none of them worked. I have been trying this since a long time but no luck yet.

How to achieve the asked task.


Solution

  • I couldn't get a workaround of having my custom annotation @ReadOnlyConnection(like @Transactional) at a method level,but a small heck did work for me.

    I am pasting the code snippet below.

    @Aspect
    @Component
    @EnableAspectJAutoProxy
    public class ConnectionInterceptor {
    
        private Logger logger;
        private static final String JPA_PREFIX = "findBy";
        private static final String CUSTOM_PREFIX = "read";
    
        public ConnectionInterceptor() {
            logger = LoggerFactory.getLogger(getClass());
            logger.info("ConnectionInterceptor Started");
        }
    
        @Autowired
        private EntityManager entityManager;
    
        @Pointcut("this(org.springframework.data.repository.Repository)")
        public void inRepositoryLayer() {}
    
        @Around("inRepositoryLayer()")
        public Object proceed(ProceedingJoinPoint pjp) throws Throwable {
            String methodName = pjp.getSignature().getName();
            if (StringUtils.startsWith(methodName, JPA_PREFIX) || StringUtils.startsWith(methodName, CUSTOM_PREFIX)) {
                System.out.println("I'm there!" );
                Session session = entityManager.unwrap(Session.class);
                ConnectionReadOnly readOnlyWork = new ConnectionReadOnly();
    
                try{
                    session.doWork(readOnlyWork);
                    return pjp.proceed();
                } finally {
                    readOnlyWork.switchBack();
                }
            }
            return pjp.proceed();
        }
    }
    

    So in the above code I am using a pointcut like following

    @Pointcut("this(org.springframework.data.repository.Repository)")
    public void inRepositoryLayer() {}
    

    and what it does is

    any join point (method execution only in Spring AOP) where the proxy implements the Repository interface

    You can have a look it at http://docs.spring.io/spring/docs/current/spring-framework-reference/html/aop.html

    Now all my repository read query methods either start with a prefix "findByXXX"(default spring-data-jpa readable method) or "readXXX"(custom read method with @Query annotation) which in my around method executions matched by the above pointcut. According to my requirement I am setting the JDBC Connection readOnly true.

    Session session = entityManager.unwrap(Session.class);
    ConnectionReadOnly readOnlyWork = new ConnectionReadOnly();
    

    And my ConnectionReadOnly look like following

    package com.xyz.forum.replication;
    
    import org.hibernate.jdbc.Work;
    
    import java.sql.Connection;
    import java.sql.SQLException;
    
    /**
     * Created by Bhupati Patel on 04/11/15.
     */
    public class ConnectionReadOnly implements Work {
    
        private Connection connection;
        private boolean autoCommit;
        private boolean readOnly;
    
        @Override
        public void execute(Connection connection) throws SQLException {
            this.connection = connection;
            this.autoCommit = connection.getAutoCommit();
            this.readOnly = connection.isReadOnly();
            connection.setAutoCommit(false);
            connection.setReadOnly(true);
        }
    
        //method to restore the connection state before intercepted
        public void switchBack() throws SQLException{
            connection.setAutoCommit(autoCommit);
            connection.setReadOnly(readOnly);
        }
    }
    

    So above settings work for my requirement.