为什么@Transactional 不回滚在此集成测试中保存实体?

Why is @Transactional not rolling back saving an entity in this integration test?

我有一个控制器可以接收一些表单数据,并且应该

控制器用 @Transactional 注释(虽然我读过在控制器级别上使用这个注释不是一个好主意......)用 rollbackFor = Exception.class,因为如果发生任何异常,我想回滚对任何实体所做的更改。

当我 运行 测试并检查我期望消失的实体是否存在时,它仍然存在。所以,@Transactional 似乎没有像我预期的那样工作。

ClassifiedController.java,在src/main/java/com/example/controllers:

package com.example.controllers;

import com.example.services.DefaultImageManipulationService;
import com.example.services.ImageManipulationService;
import com.example.entities.Classified;
import com.example.entities.Place;
import com.example.inbound.ClassifiedFormData;
import com.example.repositories.ClassifiedRepository;
import com.example.repositories.PlaceRepository;
import com.example.services.StorageService;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.awt.*;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

@RestController
@CrossOrigin(origins = "http://localhost:4200")
public class ClassifiedController {
    private final ClassifiedRepository classifiedRepository;
    private final PlaceRepository placeRepository;
    private final StorageService storageService;
    private final ImageManipulationService imageManipulationService;

    public ClassifiedController(ClassifiedRepository classifiedRepository,
                                PlaceRepository placeRepository,
                                StorageService storageService,
                                DefaultImageManipulationService imageManipulationService) {
        this.classifiedRepository = classifiedRepository;
        this.placeRepository = placeRepository;
        this.storageService = storageService;
        this.imageManipulationService = imageManipulationService;
    }

    @Transactional(rollbackFor = Exception.class)
    @PostMapping(path = "/classifieds", consumes = { MediaType.MULTIPART_FORM_DATA_VALUE })
    public void addClassified(@RequestPart(name="data") ClassifiedFormData classifiedFormData,
                              @RequestPart(name="images") MultipartFile[] images) {

        /* The end goal here is to get a classified and a place into the DB.
        If anything goes wrong, the transaction should be rolled back, and any saved images and thumbnails
        should be deleted. */
        List<String> filePaths = null;
        Path pathToImagesForThisClassified = null;
        String thumbnailPath = null;
        Path pathToThumbnailsForThisClassified = null;

        try {
            Classified classified = new Classified();
            classified.setSummary(classifiedFormData.getSummary());
            classified.setDescription(classifiedFormData.getDescription());
            classified.setPrice(classifiedFormData.getPrice());
            classified.setCurrency(classifiedFormData.getCurrency());
            classifiedRepository.save(classified);

            if (true) {
                throw new Exception("The saved Classified should be deleted because of the @Transactional annotation");
            }

            String idAsStr = String.valueOf(classified.getId());
            pathToImagesForThisClassified = Paths.get("images", idAsStr);
            filePaths = storageService.storeAll(pathToImagesForThisClassified, images);
            File thumbnail = imageManipulationService.resize(filePaths.get(classifiedFormData.getThumbnailIndex()),
                    new Dimension(255, 255));
            pathToThumbnailsForThisClassified = Paths.get("thumbnails", idAsStr);
            thumbnailPath = storageService.store(pathToThumbnailsForThisClassified, thumbnail);
            classified.setImagePaths(filePaths);
            classified.setThumbnailImagePath(thumbnailPath);
            classifiedRepository.save(classified);

            Place place = new Place(classified);
            place.setCountry(classifiedFormData.getCountry());
            place.setLabel(classifiedFormData.getLabel());
            place.setLatitude(Double.valueOf(classifiedFormData.getLat()));
            place.setLongitude(Double.valueOf(classifiedFormData.getLon()));
            placeRepository.save(place);
        } catch (Exception e) {
            e.printStackTrace();
            storageService.deleteRecursively(pathToImagesForThisClassified);
            storageService.deleteRecursively(pathToThumbnailsForThisClassified);
        }
    }
}

ClassifiedControllerTest.java 在 src/test/java/com/example/controllers:

package com.example.controllers;

import com.example.entities.Classified;
import com.example.entities.Place;
import com.example.inbound.ClassifiedFormData;
import com.example.repositories.ClassifiedRepository;
import com.example.repositories.PlaceRepository;
import com.example.services.StorageService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@DisplayName("ClassifiedController")
public class ClassifiedControllerTest {

    private final MockMvc mvc;
    private final ClassifiedRepository classifiedRepository;
    private final PlaceRepository placeRepository;
    private final StorageService storageService;

    public ClassifiedControllerTest(MockMvc mvc, ClassifiedRepository classifiedRepository,
                                    PlaceRepository placeRepository, StorageService storageService) {
        this.mvc = mvc;
        this.classifiedRepository = classifiedRepository;
        this.placeRepository = placeRepository;
        this.storageService = storageService;
    }

    @DisplayName("Any saved entities and files are deleted if an exception is encountered")
    @Test
    public void givenInvalidFormData_whenPosted_thenStatus400AndClean() throws Exception {
        // GIVEN
        ClassifiedFormData classifiedFormData = new ClassifiedFormData();
        classifiedFormData.setCountry("Spain");
        classifiedFormData.setCurrency("EUR");
        classifiedFormData.setSummary("Test");
        classifiedFormData.setDescription("Test");
        classifiedFormData.setLabel("Test");
        classifiedFormData.setPrice(32.45);
        classifiedFormData.setThumbnailIndex((byte)1);
        classifiedFormData.setLat("42.688630");
        classifiedFormData.setLon("-2.945620");

        MockMultipartFile classified = new MockMultipartFile("data", "", "application/json",
                ("{\"summary\":\"feefwfewfew\",\"description\":\"fewfewfewfewfwe\",\"price\":\"34\"," +
                        "\"currency\":\"CAD\",\"thumbnailIndex\":0,\"lat\":\"52.2460367\",\"lon\":\"0.7125173\"," +
                        "\"label\":\"Bury St Edmunds, Suffolk, East of England, England, IP33 1BZ, United Kingdom\"," +
                        "\"country\":\"United Kingdom\"}").getBytes());

        byte[] image1Bytes = getClass().getClassLoader().getResourceAsStream("test_image.jpg").readAllBytes();
        byte[] image2Bytes = getClass().getClassLoader().getResourceAsStream("test_image.jpg").readAllBytes();

        String image1Filename = "image1.jpg";
        String image2Filename = "image2.jpg";

        MockMultipartFile image1 =
                new MockMultipartFile("images", image1Filename,"image/jpeg", image1Bytes);
        MockMultipartFile image2 =
                new MockMultipartFile("images", image2Filename, "image/jpeg", image2Bytes);

        Path expectedImagePath = Paths.get("images", "5");
        Path expectedThumbnailPath = Paths.get("thumbnails", "5");

        // WHEN-THEN
        mvc.perform(MockMvcRequestBuilders.multipart("/classifieds")
                .file(classified)
                .file(image1)
                .file(image2)
                .contentType(MediaType.MULTIPART_FORM_DATA_VALUE))
                .andExpect(status().isOk());

        Optional<Classified> classifiedOptional = classifiedRepository.findById((long)5);
        assertFalse(classifiedOptional.isPresent()); // This is the assertion that is failing
        Optional<Place> placeOptional = placeRepository.findByClassifiedId(5);
        assertFalse(placeOptional.isPresent());

        Resource image1AsResource = storageService.loadAsResource(expectedImagePath, image1Filename);
        Resource image2AsResource = storageService.loadAsResource(expectedImagePath, image2Filename);
        Resource thumbnailAsResource = storageService.loadAsResource(expectedThumbnailPath, "thumbnail.jpg");

        assertFalse(image1AsResource.exists());
        assertFalse(image2AsResource.exists());
        assertFalse(thumbnailAsResource.exists());
    }
}

测试结果:

java.lang.Exception: The saved Classified should be deleted because of the @Transactional annotation
    at com.example.controllers.ClassifiedController.addClassified(ClassifiedController.java:67)
    at com.example.controllers.ClassifiedController$$FastClassBySpringCGLIB$50f537.invoke(<generated>)
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
[some lines omitted for brevity]
expected: <false> but was: <true>
org.opentest4j.AssertionFailedError: expected: <false> but was: <true>
    at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55)
    at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:40)
    at org.junit.jupiter.api.AssertFalse.assertFalse(AssertFalse.java:35)
    at org.junit.jupiter.api.Assertions.assertFalse(Assertions.java:210)
    at com.example.controllers.ClassifiedControllerTest.givenInvalidFormData_whenPosted_thenStatus400AndClean(ClassifiedControllerTest.java:148)

该方法从不抛出异常,因此 Spring 没有理由回滚事务。

如果它确实抛出异常(例如通过在 catch 块的末尾添加 throw new RuntimeException(e);),那么 Spring 将回滚事务。

您正在 addClassified(@RequestPart(name="data") 方法的 catch 块中捕获冒泡异常。

你必须在catch块中抛出异常或者移除catch块,这样spring的拦截器才能知道抛出异常并回滚事务。

抛出的异常

 if (true) {
  throw new Exception("The saved Classified should be deleted because of the *@Transactional* annotation");
 }

被抓住:

 } catch (Exception e) {
  e.printStackTrace();
  // ...
 }

并且不离开 addClassified 方法,即不会传播异常。因此,Spring不会做任何事情。

在高层次上,@transactional 注释将您的代码包装成如下形式:

UserTransaction utx = entityManager.getTransaction();

try {
 utx.begin();

 addClassified(); // your actual method invocation

 utx.commit();
} catch (Exception ex) {
 utx.rollback();
 throw ex;
}

TL;DR:您可以删除 try-catch 或(重新)在您的 catch 块中抛出一个新异常。

@Actully 你不应该处理异常,因为 spring 不知道异常所以不能回滚 并且您有要求,如果发生异常,则必须删除文件。比你喜欢的要多。

public void addClassified(@RequestPart(name="data") ClassifiedFormData classifiedFormData,  @RequestPart(name="images") MultipartFile[] images) {

        //  to delete file  
        boolean flag = true;    

        try {
            Classified classified = new Classified();
            classified.setSummary(classifiedFormData.getSummary());
            classified.setDescription(classifiedFormData.getDescription());
            classified.setPrice(classifiedFormData.getPrice());
            classified.setCurrency(classifiedFormData.getCurrency());
            classifiedRepository.save(classified);

            if (true) {
                throw new Exception("The saved Classified should be deleted because of the @Transactional annotation");
            }

            String idAsStr = String.valueOf(classified.getId());
            pathToImagesForThisClassified = Paths.get("images", idAsStr);
            filePaths = storageService.storeAll(pathToImagesForThisClassified, images);
            File thumbnail = imageManipulationService.resize(filePaths.get(classifiedFormData.getThumbnailIndex()),
                    new Dimension(255, 255));
            pathToThumbnailsForThisClassified = Paths.get("thumbnails", idAsStr);
            thumbnailPath = storageService.store(pathToThumbnailsForThisClassified, thumbnail);
            classified.setImagePaths(filePaths);
            classified.setThumbnailImagePath(thumbnailPath);
            classifiedRepository.save(classified);

            Place place = new Place(classified);
            place.setCountry(classifiedFormData.getCountry());
            place.setLabel(classifiedFormData.getLabel());
            place.setLatitude(Double.valueOf(classifiedFormData.getLat()));
            place.setLongitude(Double.valueOf(classifiedFormData.getLon()));
            placeRepository.save(place);

            flag = false;
        } finally {

             if(flag){
                storageService.deleteRecursively(pathToImagesForThisClassified);
                storageService.deleteRecursively(pathToThumbnailsForThisClassified);
             }
        }
    }