diff --git a/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java b/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java index 6e4c48e60..778b043d3 100644 --- a/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java +++ b/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java @@ -1,6 +1,8 @@ package run.halo.app.repository; import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +import static run.halo.app.model.properties.PrimaryProperties.THEME; +import static run.halo.app.model.support.HaloConst.DEFAULT_THEME_ID; import static run.halo.app.utils.FileUtils.copyFolder; import static run.halo.app.utils.FileUtils.deleteFolderQuietly; import static run.halo.app.utils.VersionUtil.compareVersion; @@ -14,6 +16,7 @@ import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import org.springframework.util.Assert; @@ -25,7 +28,6 @@ import run.halo.app.exception.ServiceException; import run.halo.app.exception.ThemeNotSupportException; import run.halo.app.handler.theme.config.support.ThemeProperty; import run.halo.app.model.entity.Option; -import run.halo.app.model.properties.PrimaryProperties; import run.halo.app.model.support.HaloConst; import run.halo.app.theme.ThemePropertyScanner; import run.halo.app.utils.FileUtils; @@ -37,7 +39,8 @@ import run.halo.app.utils.FileUtils; */ @Repository @Slf4j -public class ThemeRepositoryImpl implements ThemeRepository { +public class ThemeRepositoryImpl + implements ThemeRepository, ApplicationListener { private final OptionRepository optionRepository; @@ -45,6 +48,8 @@ public class ThemeRepositoryImpl implements ThemeRepository { private final ApplicationEventPublisher eventPublisher; + private volatile ThemeProperty currentTheme; + public ThemeRepositoryImpl(OptionRepository optionRepository, HaloProperties properties, ApplicationEventPublisher eventPublisher) { @@ -55,19 +60,35 @@ public class ThemeRepositoryImpl implements ThemeRepository { @Override public String getActivatedThemeId() { - return optionRepository.findByKey(PrimaryProperties.THEME.getValue()) - .map(Option::getValue) - .orElse(HaloConst.DEFAULT_THEME_ID); + return getActivatedThemeProperty().getId(); } @Override public ThemeProperty getActivatedThemeProperty() { - return fetchThemePropertyByThemeId(getActivatedThemeId()).orElseThrow(); + ThemeProperty themeProperty = this.currentTheme; + if (themeProperty == null) { + synchronized (this) { + if (this.currentTheme == null) { + // get current theme id + String currentThemeId = this.optionRepository.findByKey(THEME.getValue()) + .map(Option::getValue) + .orElse(DEFAULT_THEME_ID); + // fetch current theme + this.currentTheme = this.getThemeByThemeId(currentThemeId); + } + } + } + return this.currentTheme; } @Override public Optional fetchThemePropertyByThemeId(String themeId) { - return listAll().stream() + if (StringUtils.equals(themeId, getActivatedThemeId())) { + return Optional.of(getActivatedThemeProperty()); + } + + return ThemePropertyScanner.INSTANCE.scan(getThemeRootPath(), null) + .stream() .filter(property -> Objects.equals(themeId, property.getId())) .findFirst(); } @@ -80,13 +101,13 @@ public class ThemeRepositoryImpl implements ThemeRepository { @Override public void setActivatedTheme(@NonNull String themeId) { Assert.hasText(themeId, "Theme id must not be blank"); - final var newThemeOption = optionRepository.findByKey(PrimaryProperties.THEME.getValue()) + final var newThemeOption = optionRepository.findByKey(THEME.getValue()) .map(themeOption -> { // set theme id themeOption.setValue(themeId); return themeOption; }) - .orElseGet(() -> new Option(PrimaryProperties.THEME.getValue(), themeId)); + .orElseGet(() -> new Option(THEME.getValue(), themeId)); optionRepository.save(newThemeOption); eventPublisher.publishEvent(new OptionUpdatedEvent(this)); @@ -163,4 +184,20 @@ public class ThemeRepositoryImpl implements ThemeRepository { private Path getThemeRootPath() { return Paths.get(properties.getWorkDir()).resolve("templates/themes"); } + + @Override + public void onApplicationEvent(OptionUpdatedEvent event) { + synchronized (this) { + this.currentTheme = null; + } + } + + @NonNull + protected ThemeProperty getThemeByThemeId(String themeId) { + return ThemePropertyScanner.INSTANCE.scan(getThemeRootPath(), null) + .stream() + .filter(property -> Objects.equals(themeId, property.getId())) + .findFirst() + .orElseThrow(); + } } diff --git a/src/test/java/run/halo/app/repository/ThemeRepositoryImplTest.java b/src/test/java/run/halo/app/repository/ThemeRepositoryImplTest.java new file mode 100644 index 000000000..ec2e34d3a --- /dev/null +++ b/src/test/java/run/halo/app/repository/ThemeRepositoryImplTest.java @@ -0,0 +1,102 @@ +package run.halo.app.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static run.halo.app.model.properties.PrimaryProperties.THEME; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.context.ApplicationEventPublisher; +import run.halo.app.config.properties.HaloProperties; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.model.support.HaloConst; + +/** + * Theme repository impl test. + * + * @author johnniang + */ +class ThemeRepositoryImplTest { + + @InjectMocks + @Spy + ThemeRepositoryImpl themeRepository; + + @Mock + OptionRepository optionRepository; + + @Mock + HaloProperties haloProperties; + + @Mock + ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void getActivatedThemeBySingleThread() { + ThemeProperty expectedTheme = new ThemeProperty(); + expectedTheme.setId(HaloConst.DEFAULT_THEME_ID); + expectedTheme.setActivated(true); + + given(optionRepository.findByKey(THEME.getValue())).willReturn(Optional.empty()); + doReturn(expectedTheme).when(themeRepository) + .getThemeByThemeId(HaloConst.DEFAULT_THEME_ID); + + ThemeProperty resultTheme = themeRepository.getActivatedThemeProperty(); + assertEquals(expectedTheme, resultTheme); + + verify(optionRepository, times(1)).findByKey(any()); + verify(themeRepository, times(1)).getThemeByThemeId(any()); + } + + @Test + void getActivatedThemeByMultiThread() throws InterruptedException { + ThemeProperty expectedTheme = new ThemeProperty(); + expectedTheme.setId(HaloConst.DEFAULT_THEME_ID); + expectedTheme.setActivated(true); + + given(optionRepository.findByKey(THEME.getValue())).willReturn(Optional.empty()); + doReturn(expectedTheme).when(themeRepository) + .getThemeByThemeId(HaloConst.DEFAULT_THEME_ID); + + ExecutorService executorService = Executors.newFixedThreadPool(10); + // define tasks + List> tasks = IntStream.range(0, 10) + .mapToObj( + i -> (Callable) () -> themeRepository.getActivatedThemeProperty()) + .collect(Collectors.toList()); + + // invoke and get results + executorService.invokeAll(tasks).forEach(future -> { + try { + assertEquals(expectedTheme, future.get(100, TimeUnit.MILLISECONDS)); + } catch (Exception e) { + throw new RuntimeException("Failed to get task result!", e); + } + }); + + verify(optionRepository, times(1)).findByKey(any()); + verify(themeRepository, times(1)).getThemeByThemeId(any()); + } + +} \ No newline at end of file