Velocity可配置消息模版封装

别等时光非礼了梦想. 提交于 2020-04-05 19:10:49
背景:项目中时常有一些需要发消息给通知的场景(不是指消息队列..)。
     消息的形式有许多比如邮件,比如钉钉。
     消息的内容由业务决定,而且其中可能还包含动态参数。
     消息的接收人可能是固定的人,也可能是动态不确定的人。
     消息的发送时机一般都是在一段业务逻辑处理完成之后。
     现在在做的项目,发送消息使用了Velocity 模板引擎。
     在资源目录下有一个模版文件夹,专门放置.vm的消息模版。
     每当有新的需要发送消息的场景就新建一个模版然后开发业务代码把动态参数和模版整合。
     一般业务逻辑很少变化,可是出于体验或者什么的考量产品会经常调整消息的内容,这个时候就要改模版内容,然后上线。

优化:为了避免由于改动模版内容引发频繁上线,将消息模版做成可配置的。
     需要抽离出来的就是三个部分:消息内容,消息接收人,消息发送时机。

1.思路:

2.方案:

3.实现:

3.1.涉及到的包:

<dependency>
    <groupId>org.apache.velocity</groupId>
    <artifactId>velocity</artifactId>
    <version>1.7</version>
    <exclusions>
        <exclusion>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
       </exclusion>
    </exclusions>
</dependency>

3.2.取模版数据并重写ResourceLoader

@Service
public class VelocityTemplateServiceImpl implements IVelocityTemplateService {
    private static final Logger logger = LoggerFactory.getLogger(VelocityTemplateServiceImpl.class);
    ...

    /**
     * 模版实体
     */
    private static NotifyTemplateDto notifyTemplateDto;

    /**
     * 获取消息内容,这里涉及邮件和钉钉两种消息格式
     *
     * @param scene   场景
     * @param attrMap 属性map 
     * @return 消息实体
     */
    @Override
    public NotifyTemplateDto fetchContent(String scene, Map<String, Object> attrMap) {
        notifyTemplateDto = new NotifyTemplateDto();
        if (StringUtils.isBlank(scene)) {
            return notifyTemplateDto;
        }
        
        //通过场景查出场景下配置的模版取最新
        ...
        notifyTemplateDto = selectNewestOne();

        //整合邮件模版内容
        String mailContent = mergeContent(CustomMailTemplateLoader.class.getName(), TemplateTypeEnums.MAIL.name(), attrMap);
        notifyTemplateDto.setMailTemplate(mailContent);

        //整合钉钉模版内容
        String ddContent = mergeContent(CustomDDTemplateLoader.class.getName(), TemplateTypeEnums.DD.name(), attrMap);
        notifyTemplateDto.setDDTemplate(ddContent);
        return notifyTemplateDto;
    }

    /**
     * 整合消息内容
     *
     * @param resourceLoaderClass   自定义资源加载类
     * @param templateType 模版类型(邮件/钉钉) 
     * @param attrMap 参数map
     * @return 消息内容
     */
    private String mergeContent(String resourceLoaderClass, String templateType, Map<String, Object> attrMap) {
        Properties p = new Properties();
        //自定义模版资源加载器
        p.setProperty(Velocity.RESOURCE_LOADER, "CustomResourceLoader");
        //注意这里会创建resourceLoaderClass的实例
        p.setProperty("CustomResourceLoader.resource.loader.class", resourceLoaderClass);
        p.setProperty(Velocity.INPUT_ENCODING, "utf-8");
        p.setProperty(Velocity.OUTPUT_ENCODING, "utf-8");

        VelocityEngine ve = new VelocityEngine();
        //初始化模版引擎
        ve.init(p);
        Template template = ve.getTemplate(templateType, "UTF-8");

        VelocityContext context = new VelocityContext();
        //动态参数处理
        for (Map.Entry<String, Object> entry : attrMap.entrySet()) {
            context.put(entry.getKey(), entry.getValue());
        }
        StringWriter writer = new StringWriter();
        //模版内容整合
        template.merge(context, writer);
        return writer.toString();
    }

    /**
     * 重写资源加载方法
     *
     * @param
     * @return
     */
    public static class CustomMailTemplateLoader extends ResourceLoader {

        @Override
        public void init(ExtendedProperties configuration) {
        }

        @Override
        public InputStream getResourceStream(String source) throws ResourceNotFoundException {
            try {
                return new ByteArrayInputStream(notifyTemplateDto.getMailTemplate().getBytes("utf-8"));
            } catch (UnsupportedEncodingException e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        public boolean isSourceModified(Resource resource) {
            return false;
        }

        @Override
        public long getLastModified(Resource resource) {
            return 0;
        }
    }

    /**
     * 重写资源加载方法
     *
     * @param
     * @return
     */
    public static class CustomDDTemplateLoader extends ResourceLoader {

        @Override
        public void init(ExtendedProperties configuration) {
        }

        @Override
        public InputStream getResourceStream(String source) throws ResourceNotFoundException {
            try {
                return new ByteArrayInputStream(notifyTemplateDto.getDDTemplate().getBytes("utf-8"));
            } catch (UnsupportedEncodingException e) {
                throw new RuntimeException(e);
            }
        }

        @Override
        public boolean isSourceModified(Resource resource) {
            return false;
        }

        @Override
        public long getLastModified(Resource resource) {
            return 0;
        }
    }
}

解读:在外部类中持有一个静态NotifyTemplateDto对象,用于存放从数据库中查询到的模版内容,这个实体中包含各种格式的消息模版(邮件、钉钉etc...),而查询数据库的操作放在外部类的一个统一方法中。然后根据消息格式的不同,定义多个静态内部类CustomXXXTemplateLoader继承ResourceLoader重写其getResourceStream的方法,实现就很简单直接从NotifyTemplateDto对象里取相应格式模版返回即可。

为什么使用内部类?

也可以不使用内部类。将查询库的逻辑写在重写的getResourceStream方法中,只不过这样的话多种消息类型就要写多个查询的逻辑,比较冗余。使用内部类是为了能够将查库逻辑抽离出来,结果放在一个全局变量中,这样getResourceStream方法只需要从全局变量中取值。

为什么内部类得是静态?

在初始化模版引擎时会生成自定义资源加载器的实例,而普通内部类的实例生成需要外部类的实例引用,而这时并没有外部类的实例引用,而静态内部类通过外部类的类名就可初始化。当没有声明为static时,模版引擎初始化会报如下错误:

org.apache.velocity.exception.VelocityException: Problem instantiating the template loader: com.xxx.xxx.xxx.service.impl.tools.VelocityTemplateServiceImpl$CustomMailTemplateLoader.
Look at your properties file and make sure the
name of the template loader is correct.
    at org.apache.velocity.runtime.resource.loader.ResourceLoaderFactory.getLoader(ResourceLoaderFactory.java:60)
    at org.apache.velocity.runtime.resource.ResourceManagerImpl.initialize(ResourceManagerImpl.java:130)
    at org.apache.velocity.runtime.RuntimeInstance.initializeResourceManager(RuntimeInstance.java:730)
    at org.apache.velocity.runtime.RuntimeInstance.init(RuntimeInstance.java:263)
    at org.apache.velocity.runtime.RuntimeInstance.init(RuntimeInstance.java:646)
    at org.apache.velocity.app.VelocityEngine.init(VelocityEngine.java:116)
   ...
Caused by: java.lang.InstantiationException: com.xxx.xxx.xxx.service.impl.tools.VelocityTemplateServiceImpl$CustomMailTemplateLoader
    at java.lang.Class.newInstance(Class.java:427)
    at org.apache.velocity.util.ClassUtils.getNewInstance(ClassUtils.java:105)
    at org.apache.velocity.runtime.resource.loader.ResourceLoaderFactory.getLoader(ResourceLoaderFactory.java:46)
    ... 64 common frames omitted
Caused by: java.lang.NoSuchMethodException: com.xxx.xxx.xxx.service.impl.tools.VelocityTemplateServiceImpl$CustomMailTemplateLoader.<init>()
    at java.lang.Class.getConstructor0(Class.java:3082)
    at java.lang.Class.newInstance(Class.java:412)
    ... 66 common frames omitted

当然了,既然想要被静态内部类访问,全局的NotifyTemplateDto对象就也得声明为静态。

3.3.自定义注解

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface NeedNotify {
    /**
     * 消息通知场景
     */
    String scene() default "";
}

需要发送消息的方法使用注解标注:

    /**
     * 需要发送消息的场景返回结果为消息模版所需的填充参数
     *
     * @param
     * @return
     */
    @NeedNotify(scene = "apply_success")//指定消息场景
    @Override
    public Map<String, Object> handleBusiness(BusinessParamsDto dto) {
        
        ...//业务逻辑处理

        //消息所需的动态参数
        Map<String, Object> notifyMap = Maps.newHashMap();
        notifyMap.put(TemplateAttrEnums.url.name(), EnvUtils.pageUrl);
        //如果动态参数较多可封装为一个实体传入,模版中通过 $obj.属性名 进行配置
        notifyMap.put(TemplateAttrEnums.obj.name(), dto);
        notifyMap.put(TemplateAttrEnums.title.name(), "测试");
        //放入动态的收件人
        notifyMap.put("recipients", "xxx");
        return notifyMap;
    }

@NeedNotify注解需要指定了消息场景

如果项目中的动态参数比较统一,比如项目首页地址等,可以提炼出一个属性的枚举,在配置模版时从这个枚举中选择属性,这样模版的配置和动态参数的绑定比较容易统一

3.4.设置切面

统一在业务方法执行完成之后发送消息

@Aspect
@Component
public class NotificationAspect {
    private static final Logger logger = LoggerFactory.getLogger(NotificationAspect.class);
    private static final String DEFAULT_TITLE = "消息通知";
    @Autowired
    private IVelocityTemplateService velocityTemplateService;

    /**
     * 统一消息通知
     *
     * @param
     * @return
     */
    @AfterReturning(pointcut = "execution(* com.xxx.xxx.xxx.service.impl.*.*(..)) && @annotation(needNotify)", returning = "result")
    public void after(JoinPoint joinPoint, NeedNotify needNotify, Object result) {
        try {
            Map<String, Object> resultMap = (Map<String, Object>) result;
            if (resultMap.get(TemplateAttrEnums.obj.name()) == null) {
                //目标方法的入参可用于模版填充
                resultMap.put(TemplateAttrEnums.obj.name(), joinPoint.getArgs());
            }
            //根据注解中的指定的场景获取到消息模版
            NotifyTemplateDto notifyTemplateDto = velocityTemplateService.fetchContent(needNotify.scene(), resultMap);

            //模版配置校验
            if (notifyTemplateDto == null || (StringUtils.isBlank(notifyTemplateDto.getRecipients()) && resultMap.get(TemplateAttrEnums.recipients.name()) == null) || (StringUtils.isBlank(notifyTemplateDto.getMailTemplate()) && StringUtils.isBlank(notifyTemplateDto.getDDTemplate()))) {
                logger.warn("场景:{} 下消息模版配置异常", needNotify.scene());
                return;
            }
            ...
            if (StringUtils.isNotBlank(notifyTemplateDto.getMailTemplate())) {
                sedMail(...);//具体发送邮件消息的封装实现
            }

            if (StringUtils.isNotBlank(notifyTemplateDto.getDDTemplate())) {
                sedDD(...);//具体发送钉钉消息的封装实现
            }
        } catch (Exception e) {
            logger.error("消息通知异常:", e);
        }
    }

@AfterReturning注解的切面在目标方法后执行,且能获取到目标方法的返回值。其属性pointcut定义了切面起作用的范围,returning定义返回的接收名,可用该定义取到方法的返回值。方法中第一个参数刚开始用成了ProceedJoinPoint结果报错,注意ProceedJoinPoint只能用于around切面。

测试成功,over。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!