Github地址:github.com/halo-dev/halo

这里选择v1.0.0的版本先进行分析,这是一个最初的版本,没有前后端分离,还是使用的freemarker模板引擎进行显示的。

配置文件

主要配置文件如下

​​

HaloProperties:

@Data
@ConfigurationProperties("halo")
public class HaloProperties {

    /**
     * Doc api disabled. (Default is true)
     */
    private boolean docDisabled = true;

    /**
     * Production env. (Default is true)
     */
    private boolean productionEnv = true;

    /**
     * Authentication enabled
     */
    private boolean authEnabled = true;

    /**
     * Work directory.
     */
    private String workDir = HaloConst.USER_HOME + "/.halo/";

    public HaloProperties() throws IOException {
        // Create work directory if not exist
        Files.createDirectories(Paths.get(workDir));
    }
}

工作文件目录为:用户目录下的./halo/​文件夹下

如果这个目录不存在就创建一个。

过滤器

​​

过滤器有两个,一个是Cors的过滤器,一个是日志过滤器

日志过滤器

日志过滤器如下:

@Slf4j
public class LogFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String remoteAddr = ServletUtil.getClientIP(request);

        log.debug("");
        log.debug("Starting url: [{}], method: [{}], ip: [{}]", request.getRequestURL(), request.getMethod(), remoteAddr);

        // Set start time
        long startTime = System.currentTimeMillis();

        // Do filter
        filterChain.doFilter(request, response);

        log.debug("Ending   url: [{}], method: [{}], ip: [{}], status: [{}], usage: [{}] ms", request.getRequestURL(), request.getMethod(), remoteAddr, response.getStatus(), (System.currentTimeMillis() - startTime));
        log.debug("");
    }
}

继承自:OncePerRequestFilter,用以记录和输出日志。

这里可以对比一下若依的日志拦截与处理,下面是我之前写的类似若依的处理方式:主要核心使用的是AOP切面编程,根据注解拦截请求的Controller,然后在这些来记录log。

/**
 * 操作日志记录处理(写操作)
 *
 * @author maoyan
 */
@Aspect
@Component
@Slf4j
public class LogAspect {

  @Autowired
  private IDqUserService iDqUserService;

  @Autowired
  private IDqOperLogService iDqOperLogService;

  /**
   * 拦截注解下的方法
   */
  @Pointcut("@annotation(com.maoyan.quickdevelop.common.annotation.Log)")
  public void logPointCut() {
  }

  @Before("logPointCut()")
  public void doBefore(JoinPoint joinPoint) {
    // 如果是用户注册则不校验登陆状态
    // 获得注解
    Log controllerLog = null;
    try {
      controllerLog = getAnnotationLog(joinPoint);
      if (controllerLog == null) {
        return;
      }
      if (!StringUtils.equals(controllerLog.title(), "用户注册")) {
        // 校验用户登陆状态
        handleDqUserStatus();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }

  }

  /***
   * @Author: 猫颜
   * @Description: 处理完方法后执行
   * @DateTime: 下午6:11 2021/7/27
   * @Params:
   * @param joinPoint
   * @param jsonResult
   * @Return
   */
  @AfterReturning(pointcut = "logPointCut()", returning = "jsonResult")
  public void doAfterReturning(JoinPoint joinPoint, Object jsonResult) {
    handleLog(joinPoint, null, jsonResult);
  }

  /***
   * @Author: 猫颜
   * @Description: 拦截异常操作
   * @DateTime: 下午4:24 2021/7/28
   * @Params:
   * @param joinPoint
   * @param e
   * @Return
   */
  @AfterThrowing(value = "logPointCut()", throwing = "e")
  public void doAfterThrowing(JoinPoint joinPoint, Exception e) {
    log.error("拦截异常");
    handleLog(joinPoint, e, null);
  }

  /**
   * 对写操作的用户状态进行验证
   */
  protected void handleDqUserStatus() {
    /**
     * 这里直接拦截所有的写操作,进行登陆判断,没有登陆无法进行写操作,这样在其他写操作中就不需要验证登陆了。
     */
    // 这里可以直接验证登陆状态了
    long loginIdAsLong = StpUtil.getLoginIdAsLong();
    DqUser dqUser = iDqUserService.selectDqUserById(loginIdAsLong);
    boolean dqUserIsNull = Optional.ofNullable(dqUser).isPresent();
    if (!dqUserIsNull) {
      throw new CustomException("您已经被封禁", HttpStatus.FORBIDDEN);
    }
  }

  protected void handleLog(final JoinPoint joinPoint, final Exception e, Object jsonResult) {
    DqUser nowDqUser = new DqUser();
    try {
      // 获得注解
      Log controllerLog = getAnnotationLog(joinPoint);
      if (controllerLog == null) {
        return;
      }
      if (!StringUtils.equals(controllerLog.title(), "用户注册")) {
        // 获取当前的用户
        nowDqUser = iDqUserService.selectDqUserById(StpUtil.getLoginIdAsLong());
      } else {
        nowDqUser.setUserName("用户注册");
      }

      // *========数据库日志=========*//
      DqOperLog operLog = new DqOperLog();
      operLog.setStatus("0");
      // 请求的地址
      String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
      operLog.setOperIp(ip);
      // 返回参数
      operLog.setJsonResult(joinPoint.toLongString());

      operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
      if (nowDqUser != null) {
        operLog.setOperUserName(nowDqUser.getUserName());
      }

      if (e != null) {
        operLog.setStatus("1");
        operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 255));
      }
      // 设置方法名称
      String className = joinPoint.getTarget().getClass().getName();
      String methodName = joinPoint.getSignature().getName();
      operLog.setMethod(className + "." + methodName + "()");
      // 设置请求方式
      operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
      operLog.setOperTime(DateUtils.getNowDate());
      // 处理设置注解上的参数
      getControllerMethodDescription(joinPoint, controllerLog, operLog);
      // 保存数据库
      iDqOperLogService.insertDqOperLog(operLog);
    } catch (Exception exp) {
      // 记录本地异常日志
      log.error("==前置通知异常==");
      log.error("异常信息:{}", exp.getMessage());
      exp.printStackTrace();
    }


  }

  /**
   * 获取注解中对方法的描述信息 用于Controller层注解
   *
   * @param log     日志
   * @param operLog 操作日志
   * @throws Exception
   */
  public void getControllerMethodDescription(JoinPoint joinPoint, Log log, DqOperLog operLog) throws Exception {
    // 设置action动作
    operLog.setBusinessType(log.businessType().ordinal());
    // 设置标题
    operLog.setTitle(log.title());
    // 是否需要保存request,参数和值
    if (log.isSaveRequestData()) {
      // 获取参数的信息,传入到数据库中。
      setRequestValue(joinPoint, operLog);
    }
  }

  /**
   * 获取请求的参数,放到log中
   *
   * @param operLog 操作日志
   * @throws Exception 异常
   */
  private void setRequestValue(JoinPoint joinPoint, DqOperLog operLog) throws Exception {
    String requestMethod = operLog.getRequestMethod();
    if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
      String params = Arrays.asList(joinPoint.getArgs()).toString();
      operLog.setOperParam(StringUtils.substring(params, 0, 255));
    } else {
      Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
      operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 255));
    }
  }


  /**
   * 是否存在注解,如果存在就获取
   */
  private Log getAnnotationLog(JoinPoint joinPoint) throws Exception {
    Signature signature = joinPoint.getSignature();
    MethodSignature methodSignature = (MethodSignature) signature;
    Method method = methodSignature.getMethod();

    if (method != null) {
      return method.getAnnotation(Log.class);
    }
    return null;
  }

}

OncePerRequestFilter

​OncePerRequestFilter​ 是 Spring 框架中提供的一个过滤器类,它实现了 javax.servlet.Filter​ 接口。该过滤器确保在一次请求中只会被调用一次,而不会重复执行。这可以确保在请求处理的整个生命周期内,过滤器的逻辑只会执行一次,而不管请求中的资源是静态资源还是动态生成的。

这类过滤器通常用于执行与请求相关的操作,如身份验证、授权、日志记录等。由于它只在请求处理的开始阶段执行一次,适合用于执行一些初始化或前置处理的逻辑。

在 Spring Security 中,许多内置的过滤器都继承自 OncePerRequestFilter​,例如 UsernamePasswordAuthenticationFilter​ 等。你也可以自定义实现 OncePerRequestFilter​ 来创建自己的一次性过滤器,以满足特定需求。

要使用 OncePerRequestFilter​,你需要扩展该类并实现其 doFilterInternal​ 方法,该方法包含了你想要在每个请求中执行的逻辑。

Cors过滤器

halo的代码如下

public class CorsFilter extends GenericFilterBean {

    private final static String ALLOW_HEADERS = StringUtils.joinWith(",", HttpHeaders.CONTENT_TYPE, AdminAuthenticationFilter.ADMIN_TOKEN_HEADER_NAME);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        // Set customized header
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, httpServletRequest.getHeader(HttpHeaders.ORIGIN));
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, ALLOW_HEADERS);
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "3600");

        if (!CorsUtils.isPreFlightRequest(httpServletRequest)) {
            chain.doFilter(httpServletRequest, httpServletResponse);
        }
    }
}

我之前写的开发模板的代码如-->配置CORS

都是配置cors的两种不同的方式。

拦截器

​​

可以看到主要包含了文件和主题这两大部分

监听器

只有一个StartedListener​监听器

主要代码如下:

@Slf4j
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StartedListener implements ApplicationListener<ApplicationStartedEvent> {

    @Autowired
    private HaloProperties haloProperties;

    @Autowired
    private OptionService optionService;

    @Autowired
    private ThemeService themeService;

    @Autowired
    private UserService userService;

    @Override
    public void onApplicationEvent(ApplicationStartedEvent event) {
        // save halo version to database
        this.printStartInfo();
        this.initThemes();
    }

    private void printStartInfo() {
        String blogUrl = optionService.getBlogBaseUrl();

        log.info("Halo started at         {}", blogUrl);
        log.info("Halo admin started at   {}/admin", blogUrl);
        if (!haloProperties.isDocDisabled()) {
            log.debug("Halo doc was enable at  {}/swagger-ui.html", blogUrl);
        }
    }

还有一部分是跟主题相关的,暂时先不看

主要注解为
​@Configuration​和@Order(Ordered.HIGHEST_PRECEDENCE)​
设置为配置类和优先级最高。

实现了ApplicationListener<ApplicationStartedEvent>​接口,重写了onApplicationEvent​方法,即在应用启动的时候,执行

this.printStartInfo();
this.initThemes();

这两个方法,printStartInfo​方法主要是输出配置相关信息。

Event事件

在Spring Boot中,事件(Event)是Spring框架的一部分,是一种用于实现发布-订阅(Publish-Subscribe)模式的机制。Spring Boot继承了Spring框架的事件模型,它允许应用程序内的各个组件(例如,服务、组件、类等)通过触发和监听事件来进行解耦。

事件的核心概念

  1. 事件(Event): 事件是一个对象,通常是一个类,用于封装关于发生事件的信息。在Spring中,事件需要继承自ApplicationEvent​类。

  2. 事件发布器(EventPublisher): 事件发布器是一个接口,定义了发布事件的方法。在Spring中,ApplicationEventPublisher​是该接口的实现,可以通过注入来使用。

  3. 事件监听器(EventListener): 事件监听器是一个组件,用于接收和处理特定类型的事件。在Spring中,可以使用@EventListener​注解或实现ApplicationListener​接口来创建事件监听器。

  4. 事件监听器注册: Spring容器会自动扫描并注册使用@EventListener​注解标记的方法,或者实现了ApplicationListener​接口的类。

事件案例

事件类

import org.springframework.context.ApplicationEvent;

// 1. 创建事件类
public class MyCustomEvent extends ApplicationEvent {
    public MyCustomEvent(Object source) {
        super(source);
    }
}

事件监听器

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

// 2. 创建事件监听器
@Component
public class MyCustomEventListener {

    // 3. 使用 @EventListener 注解监听事件
    @EventListener
    public void handleCustomEvent(MyCustomEvent event) {
        // 处理事件的逻辑
        System.out.println("Received custom event: " + event.toString());
    }
}

事件发布器

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

// 4. 创建事件发布器
@Service
public class MyEventService {

    private final ApplicationEventPublisher eventPublisher;

    // 注入事件发布器
    @Autowired
    public MyEventService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    // 触发事件
    public void publishCustomEvent() {
        MyCustomEvent customEvent = new MyCustomEvent(this);
        eventPublisher.publishEvent(customEvent);
    }
}

事件的使用场景

Spring框架的事件机制通常用于解耦应用程序中的各个组件,以及在不同模块之间传递消息。以下是一些常见的使用场景:

  1. 模块间通信: 当应用程序的不同模块之间需要通信时,可以使用事件机制。一个模块产生一个事件,而另一个模块监听并响应这个事件。

  2. 业务流程触发: 在复杂的业务应用中,某个业务操作的成功或失败可能需要触发其他操作。通过事件机制,您可以将这些触发条件与具体的业务逻辑解耦,使得系统更加灵活和可维护。

  3. 异步处理: 事件机制可以用于异步处理。当某个操作完成后,可以通过事件触发异步任务的执行,而不阻塞主线程。

  4. 日志记录: 您可以使用事件机制来记录应用程序中的关键事件,以便后续分析和监控。例如,记录用户登录、订单创建等事件。

  5. 插件系统: 如果您正在构建一个可插拔的系统,您可以使用事件来通知插件或扩展模块系统中发生的变化,让插件自行决定是否要处理这些变化。

  6. 缓存刷新: 当某个数据源发生变化时,可以通过事件通知其他模块刷新相应的缓存。

总体而言,事件机制可以帮助您实现松耦合的组件,提高代码的可维护性和灵活性。在合适的场景中使用事件,能够使代码更容易理解、测试和维护。

Halo中的事件代码分析

​​

按照这个LogEvent日志事件来分析

public class LogEvent extends ApplicationEvent {

    private final LogParam logParam;

    /**
     * Create a new ApplicationEvent.
     *
     * @param source   the object on which the event initially occurred (never {@code null})
     * @param logParam login param
     */
    public LogEvent(Object source, LogParam logParam) {
        super(source);

        // Validate the log param
        ValidationUtils.validate(logParam);

        this.logParam = logParam;
    }

    public LogEvent(Object source, String logKey, LogType logType, String content) {
        this(source, new LogParam(logKey, logType, content));
    }

    public LogParam getLogParam() {
        return logParam;
    }
}

继承自ApplicationEvent来创建LogEvent,构造函数包括source和logParam,logParam即日志参数经过ValidationUtils.validate(logParam);验证之后将其初始化到对象中。验证可以看-->参数验证

再来看看日志监听器

@Component
public class LogEventListener {

    private final LogService logService;

    public LogEventListener(LogService logService) {
        this.logService = logService;
    }

    @EventListener
    @Async
    public void onApplicationEvent(LogEvent event) {
        // Convert to log
        Log logToCreate = event.getLogParam().convertTo();
        // Create log
        logService.create(logToCreate);
    }
}

监听LogEvent​事件,获取其参数将其转换之后,调用create方法将其持久化。

注意:

这里可以发现一个奇妙的点:这里的logService没有使用@Autowired​注解自动注入,这是因为在Spring中,如果一个类只有一个构造函数,并且该构造函数的参数类型是Spring容器管理的bean类型(如LogService),则Spring会自动将该bean注入到构造函数中,而无需显式使用@Autowired注解。

参数验证

实体类设计

​​

entity

主要有这么几类,先从最基础的entity看起,里面存储的都是业务相关的类,提取其共性,作为最基础的类BaseEntity​

@MappedSuperclass
@Data
@ToString
@EqualsAndHashCode
public class BaseEntity {

    /**
     * Create time.
     */
    @Column(name = "create_time", columnDefinition = "timestamp default CURRENT_TIMESTAMP")
    @Temporal(TemporalType.TIMESTAMP)
    private Date createTime;

    /**
     * Update time.
     */
    @Column(name = "update_time", columnDefinition = "timestamp default CURRENT_TIMESTAMP")
    @Temporal(TemporalType.TIMESTAMP)
    private Date updateTime;

    /**
     * Delete flag.
     */
    @Column(name = "deleted", columnDefinition = "TINYINT default 0")
    private Boolean deleted = false;

    @PrePersist
    protected void prePersist() {
        deleted = false;
        Date now = DateUtils.now();
        if (createTime == null) {
            createTime = now;
        }

        if (updateTime == null) {
            updateTime = now;
        }
    }

    @PreUpdate
    protected void preUpdate() {
        updateTime = new Date();
    }

    @PreRemove
    protected void preRemove() {
        updateTime = new Date();
    }
}

主要包含创建时间和更新时间,以及删除的标志。

关于@PrePersist,@PreUpdate和@PreRemove注解,使用Java Persistence API (JPA) 中的生命周期回调注解,用于在持久化实体的不同生命周期事件发生时执行特定的操作。这些方法通常用于设置实体的一些属性或执行一些其他的逻辑,以确保在数据库中正确地记录实体的状态变化。

  1. @PrePersist:

    • 该方法使用了 @PrePersist​ 注解,表示在实体被持久化(即插入数据库之前)时调用。

    • 在这个方法中,它将 deleted​ 属性设置为 false​,并且为 createTime​ 和 updateTime​ 属性设置值。通常,这样的设置是为了确保在首次插入实体时,这些属性都有合适的初始值。

  2. @PreUpdate:

    • 该方法使用了 @PreUpdate​ 注解,表示在实体被更新时调用。

    • 在这个方法中,它将 updateTime​ 属性设置为当前时间(通过创建一个新的 Date​ 对象)。这通常用于记录实体的最后一次更新时间。

  3. @PreRemove:

    • 该方法使用了 @PreRemove​ 注解,表示在实体从数据库中删除之前调用。

    • 在这个方法中,它同样将 updateTime​ 属性设置为当前时间。这可能是为了记录实体被删除的时间。

但是如果我们使用Mybatis的话,则需要手动来实现这些过程,例如使用拦截器等来实现。

param

例如UserParam.java

@Data
public class UserParam implements InputConverter<User> {

    @NotBlank(message = "用户名不能为空", groups = {CreateCheck.class, UpdateCheck.class})
    @Size(max = 50, message = "用户名的字符长度不能超过 {max}", groups = {CreateCheck.class, UpdateCheck.class})
    private String username;

    @NotBlank(message = "用户昵称不能为空", groups = {CreateCheck.class, UpdateCheck.class})
    @Size(max = 255, message = "用户昵称的字符长度不能超过 {max}", groups = {CreateCheck.class, UpdateCheck.class})
    private String nickname;

    @Email(message = "电子邮件地址的格式不正确", groups = {CreateCheck.class, UpdateCheck.class})
    @NotBlank(message = "电子邮件地址不能为空", groups = {CreateCheck.class, UpdateCheck.class})
    @Size(max = 127, message = "电子邮件的字符长度不能超过 {max}", groups = {CreateCheck.class, UpdateCheck.class})
    private String email;

    @Null(groups = UpdateCheck.class)
    @Size(min = 8, max = 100, message = "密码的字符长度必须在 {min} - {max} 之间", groups = {CreateCheck.class})
    private String password;

    @Size(max = 1023, message = "头像链接地址的字符长度不能超过 {max}", groups = {CreateCheck.class, UpdateCheck.class})
    private String avatar;

    @Size(max = 1023, message = "用户描述的字符长度不能超过 {max}", groups = {CreateCheck.class, UpdateCheck.class})
    private String description;

}

这一层主要是用来做参数校验的,以及接收前端传递过来的参数。

projection

这一层主要是用来存储数量信息的。

Dao层设计

这一层是与数据库交互的层次,因为使用的是JPA框架,所以先定义了一个接口BaseRepository​继承自JpaRepository​。

例如UserRepository

public interface UserRepository extends BaseRepository<User, Integer> {

    /**
     * Gets user by username.
     *
     * @param username username must not be blank
     * @return an optional user
     */
    @NonNull
    Optional<User> findByUsername(@NonNull String username);

    /**
     * Gets user by email.
     *
     * @param email email must not be blank
     * @return an optional user
     */
    @NonNull
    Optional<User> findByEmail(@NonNull String email);
}

这里有几点可以学习,

  1. 首先是这个@NonNull注解,来自import org.springframework.lang.NonNull;​通常用于标记方法参数、返回值或字段,以指示相应的元素不应该为 null​。

  2. 其次是返回类型是Optional<User>,使用Optional类型。Optional​ 是 Java 编程语言中引入的一个类,用于表示一个可能为null的值。它的设计目的是帮助开发人员更清晰地处理可能为null的情况,以避免空指针异常。