Implementing Date-Filtered Article Archives and AOP-Driven Redis Caching
Implementing Date-Filtered Article Archives
The archive endpoint accepts a POST request at /articles, expecting year and month parameters within the request body. The response adheres to a standardized envelope containing a success indicator, HTTP status code, descriptive message, and an array of article payloads.
To handle temporal filtering, the request model requires explicit fields for year and month. A normalization routine ensures single-digit month values are zero-padded to maintain consistency with database timestamp parsing.
package com.blog.core.dto;
import lombok.Data;
@Data
public class ArticleQueryDto {
private int page = 1;
private int limit = 10;
private Long categoryRef;
private Long tagRef;
private String queryYear;
private String queryMonth;
public String getNormalizedMonth() {
if (this.queryMonth == null || this.queryMonth.length() >= 2) {
return this.queryMonth;
}
return "0" + this.queryMonth;
}
}
The persistence layer utilizes MyBatis-Plus pagination capabilities. The mapper interface declares a method that accepts pagination metadata alongside the filtering criteria.
package com.blog.core.mapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.blog.core.entity.Article;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ArticleMapper {
IPage<Article> fetchArticlesByCriteria(
Page<Article> pagination,
Long categoryRef,
Long tagRef,
String queryYear,
String queryMonth
);
}
The service implementation initializes the pagination context, delegates to the mapper, and transforms the resulting entity stream into view objects.
package com.blog.core.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.blog.core.dto.ArticleQueryDto;
import com.blog.core.entity.Article;
import com.blog.core.mapper.ArticleMapper;
import com.blog.core.vo.ArticleView;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ArticleServiceImpl {
private final ArticleMapper articleMapper;
public List<ArticleView> retrievePaginatedArticles(ArticleQueryDto request) {
Page<Article> paginationContext = new Page<>(request.getPage(), request.getLimit());
IPage<Article> queryResult = articleMapper.fetchArticlesByCriteria(
paginationContext,
request.getCategoryRef(),
request.getTagRef(),
request.getQueryYear(),
request.getNormalizedMonth()
);
return queryResult.getRecords().stream()
.map(this::mapToViewObject)
.collect(Collectors.toList());
}
private ArticleView mapToViewObject(Article entity) {
return new ArticleView();
// Mapping logic omitted for brevity
}
}
Dynamic SQL construction in the mapping XML applies conditional filters for category, temporal boundaries, and tag associations. Results are ordered by publication priority and creation timsetamp.
<!-- ArticleMapper.xml -->
<resultMap id="ArticleMapping" type="com.blog.core.entity.Article">
<id property="id" column="id" />
<result property="creatorId" column="author_id"/>
<result property="commentCount" column="comment_counts"/>
<result property="publishedAt" column="create_date"/>
<result property="excerpt" column="summary"/>
<result property="heading" column="title"/>
<result property="viewCount" column="view_counts"/>
<result property="priority" column="weight"/>
<result property="contentRef" column="body_id"/>
<result property="categoryRef" column="category_id"/>
</resultMap>
<select id="fetchArticlesByCriteria" resultMap="ArticleMapping">
SELECT * FROM ms_article
<where>
<if test="categoryRef != null">
AND category_id = #{categoryRef}
</if>
<if test="queryYear != null and queryYear != '' and queryMonth != null and queryMonth != ''">
AND FROM_UNIXTIME(create_date / 1000, '%Y') = #{queryYear}
AND FROM_UNIXTIME(create_date / 1000, '%m') = #{queryMonth}
</if>
<if test="tagRef != null">
AND id IN (SELECT article_id FROM ms_article_tag WHERE tag_id = #{tagRef})
</if>
</where>
ORDER BY priority DESC, publishedAt DESC
</select>
Declarative Caching via Aspect-Oriented Programming
Memory-backed data retrieval drastically reduces latency compared to disk I/O. To standardize cache maangement, an aspect intercepts method executions, manages key generation, handles lookups, and applies expiration policies transparently.
A custom annotation defines the caching metadata directly on the target method.
package com.blog.core.config;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MethodCache {
long ttlMs() default 60000;
String prefix() default "";
}
The corresponding aspect intercepts annotated executions, serializes method arguments, generates a deterministic cache key, and coordinates with a Redis client.
package com.blog.core.config;
import com.alibaba.fastjson.JSON;
import com.blog.common.vo.StandardResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.Arrays;
import java.util.stream.Collectors;
@Aspect
@Component
@Slf4j
public class RedisCacheInterceptor {
@Autowired
private StringRedisTemplate redisClient;
@Pointcut("@annotation(com.blog.core.config.MethodCache)")
public void cacheBoundary() {}
@Around("cacheBoundary()")
public Object intercept(ProceedingJoinPoint joinPoint) throws Throwable {
String targetClass = joinPoint.getTarget().getClass().getSimpleName();
String targetMethod = joinPoint.getSignature().getName();
Object[] arguments = joinPoint.getArgs();
String paramString = Arrays.stream(arguments)
.filter(arg -> arg != null)
.map(JSON::toJSONString)
.collect(Collectors.joining());
String safeParamHash = StringUtils.isNotBlank(paramString)
? DigestUtils.md5Hex(paramString) : "";
Method executable = joinPoint.getSignature().getDeclaringType()
.getMethod(targetMethod, extractParamTypes(arguments));
MethodCache metadata = executable.getAnnotation(MethodCache.class);
String cacheKey = String.format("%s::%s::%s::%s",
metadata.prefix(), targetClass, targetMethod, safeParamHash);
String cachedPayload = redisClient.opsForValue().get(cacheKey);
if (StringUtils.isNotEmpty(cachedPayload)) {
log.info("Cache hit for {}.{}", targetClass, targetMethod);
return JSON.parseObject(cachedPayload, StandardResponse.class);
}
Object executionResult = joinPoint.proceed();
String serialized = JSON.toJSONString(executionResult);
redisClient.opsForValue().set(cacheKey, serialized, Duration.ofMillis(metadata.ttlMs()));
log.info("Cache populated for {}.{}", targetClass, targetMethod);
return executionResult;
}
private Class<?>[] extractParamTypes(Object[] args) {
return Arrays.stream(args)
.map(arg -> arg == null ? null : arg.getClass())
.toArray(Class<?>[]::new);
}
}
Applying the cache abstraction requires attaching the annotation to the desired controller endpoint.
@PostMapping("/trending")
@MethodCache(ttlMs = 300000, prefix = "trending_posts")
public StandardResponse fetchTrendingPosts() {
return postService.retrieveTopPosts(5);
}