利用泛型封装一个非常方便设置短信模板的短信服务

405 阅读4分钟

起因

事情起因是在 GRPC 的代码中看到 io.grpc.Metadataio.grpc.Context 的用法颇为巧妙,正常来说,如果我们提供一个可以存放任意对象的上文的容器 一般会采用ThreadLocal<Map<Object,Object>> 这种形式,这就导致key和value的实际类型在运行期前是不可知的,即每次从上下文容器存放或获取对象都需要硬编码和强转:

//存放
request.setAttribute("authentication", authentication);
//取出
Authentication authentication = (Authentication) request.getAttribute("authentication");

GRPC 中存放上下文对象的方法

但是在GRPC的 MetadataContext 中,通过利用Metadata.Key Context.Key 来对key做出约束并赋予泛型信息,如果想要存入或获取MetadataContext 就必须要先定义一个key:

    public static final Metadata.Key<String> AUTHORIZATION_M = 
            Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);
    public static final Context.Key<SocketAddress> REMOTE_ADDRESS_C = 
            Context.key("remote-address");

然后使用该key进行存入和获取

Metadata metadata = new Metadata();
metadata.put(AUTHORIZATION_M, "XXX");
//存入的类型和key指定的value类型不相容会无法通过编译
final Context ctx = Context.current().withValue(REMOTE_ADDRESS_C, socketAddress);
//可直接使用key来获取该上下文对象,并且获取时也不用强转
SocketAddress socketAddressNow = REMOTE_ADDRESS_C.get();

这么做的好处就是,唯一允许一个指定类型的java对象可以用来存入和获取该另外一个指定类型的对象,并且这种类型限制在编写代码开始就持续保持。

传统上下文模式的缺点

而在第一种方法里,获取Authentication对象需要使用 "authentication" 字符串,这么做有两个问题,一个是无法限制key的可见性,即任何代码都可以使用该字符串获取该上下文对象,另一方面,每次获取都需要用该对象,通常key都是字符串,而字符串类型处处硬编码的坏处就不说了,使用静态字符串变量作为常量代替也需要每次都引用该静态变量对象,但却无法从这种额外的引用中获取任何超过字符串对象以外的收益(而在Context中的key,可以直接通过Context.Key#get()方法直接获取上下文,而不是再额外调用获取上下文的方法)。而同样也无法限制value的存入类型,当使用同样的key,却对存入对象的类型毫无提示,并且每次获取都需要进行一次强制类型转换

既然这种写法这么好用,自然要学一学了,这种key究竟是怎么回事呢?其实也很简单,就是利用了泛型,Metadata.Key比较复杂这里就不展示了,让我们看看Context.Key的源码,非常简单:

  /**
   * 用于索引存储在上下文中的值的键。 Keys 使用引用相等,而 Context 不提供循环 Keys 的机制。
   * 这意味着如果不访问 Key 实例本身,就无法从 Context 访问 Key 的值。
   * 这允许对哪些代码可以在上下文中获取/设置键进行强有力的控制。
   * 例如,您可以使用 Java 可见性(私有/受保护)管理对 Key 的访问,类似于 ThreadLocal。
   * 通常,密钥存储在静态字段中。
   */
  public static final class Key<T> {
    private final String name;
    private final T defaultValue;

    Key(String name) {
      this(name, null);
    }

    Key(String name, T defaultValue) {
      this.name = checkNotNull(name, "name");
      this.defaultValue = defaultValue;
    }

    /**
     * Get the value from the {@link #current()} context for this key.
     */
    public T get() {
      return get(Context.current());
    }
    //省略其他无关紧要的方法....
  }

注意这里的key没有覆盖 equals()hashCode() 方法,这对于key来说相当重要,这取决于用什么东西来决定key的唯一性,这里就表示,除非引用同一个对象,否则不能获取该key的value(Context本身不提供keySet()values()等遍历对象的方法),当然,我们不一定需要这个功能,只是一个带泛型的Key就能让我们获益很多了。

用泛型封装一个好用的阿里云短信模板

比如,前段时间,系统接入了阿里云短信,需要封装一个短信服务,并且因为阿里云的短信模板是需要审核的,这就需要封装的短信服务能够提供一个好的模板兼容性。

1.将短信模板与java类一一对应:

以最常见的验证码短信 您的验证码${code},该验证码5分钟内有效,请勿泄漏于他人!来说:

如果不用类去对应短信模板,直接调用阿里云短信sdk的方法是这样的:

SendSmsRequest smsRequest = new SendSmsRequest()
                    .setSignName("YourSignName")
                    .setTemplateCode("SMS_111111111111")
                    .setPhoneNumbers(phoneNumber)
                    .setTemplateParam("{\"code\":"+code+"}");
client.sendSms(smsRequest);

如果每个短信模板的都得写这么一个发送短信的方法,可以是说是非常糟糕的调用体验了

因为每个模板的变量参数都需要硬编码到代码中,所以对于这种类型的模板,最好的方式就是一一对应新建一个类,每个模板(或者变量相同的每种模板)都对应一个类,使用类的字段名来代替字符串硬编码:

@Data
@AllArgsConstructor
public class AuthCodeSMS {
    private String code;
    public String toString() {
        return "您的验证码" + this.getCode() + ",该验证码5分钟内有效,请勿泄漏于他人!";
    }
}

而有了对应的类之后,起码设置模板参数会方便很多:

.setTemplateParam(JSONUtil.toJSONString(new AuthCodeSMS("123456")));

当然,最重要的是,将模板封装为类之后,我们就能利用java的泛型了!

2.利用泛型再封装短信模板

首先,我们创建一个用于短信模板的类:

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class SMSTemplate {

    /**
     * 创建一个短信模板的Key,该Key用于短信服务的模板设置
     *
     * @param templateName 模板名称,用于调试
     * @param templateCode 模板code
     * @param factory      模板实体类的工厂方法
     * @param <T>          模板实体类
     * @return 短信模板Key
     */
    public static <T> Key<T> key(String templateName, String templateCode, Supplier<T> factory) {
        return new Key<>(templateName, templateCode, factory);
    }

    /**
     * 短信模板Key,一个key代表一个短信模板
     *
     * @param <T> 短信模板的实体类
     */
    @ToString
    @EqualsAndHashCode(onlyExplicitlyIncluded = true)
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public static class Key<T> {
        @Getter
        private final String templateName;
        @Getter
        @EqualsAndHashCode.Include
        private final String templateCode;
        @ToString.Exclude
        private final Supplier<T> factory;

        public T newTemplate() {
            return factory.get();
        }
    }
}

其中的 templateName仅仅是用来说明这个模板是用来干什么的,templateCode则就是阿里云短信模板的唯一ID, Supplier<T> factory 则是模板实体类的创建方法,用于使用Key来创建对应的模板实体类,具体有什么用,我们到后面就知道了。

然后再创建一个类用来集中存放默认的短信模板的类:

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class SMSTemplates {
    /**
     * 有效时间为5分钟的验证码短信
     * <pre>{@code
     *  您的验证码${code},该验证码5分钟内有效,请勿泄漏于他人!
     * }</pre>
     */
    public static final SMSTemplate.Key<AuthCodeSMS> AUTH_CODE =
            SMSTemplate.key("验证码短信", "SMS_111111111", AuthCodeSMS::new);
    /**
     * 阿里云默认验证码短信
     * <pre>{@code
     * 您的验证码为:${code},请勿泄露于他人!
     * }</pre>
     */
    public static final SMSTemplate.Key<AuthCodeSMS> AUTH_CODE_DEFAULT =
            SMSTemplate.key("验证码短信", "SMS_22222222", AuthCodeSMS::new);

    //...其他短信模板
}

3.在含有类型信息的短信模板之上构建短信服务

那我们的阿里云短信服务就可以这么写了(这里省略了接口,实际项目可能对接多个不同的短信API供应商是用了接口抽象的,带有各种泛型参数的接口着实比较不好写)

import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Consumer;

@RequiredArgsConstructor
public class AliyunSMSService {
    private final Client client;
    private final String signName;

    public <T> AliyunSMSOptions<T> phoneNumber(String... phoneNumbers) {
        return new AliyunSMSOptions<T>(client, signName).setPhoneNumber(phoneNumbers);
    }

    public <T> AliyunSMSOptions<T> template(SMSTemplate.Key<T> key, String... phoneNumbers) {
        return phoneNumber(phoneNumbers).template(key);
    }

    public static class AliyunSMSOptions<T> {
        private final Client client;
        private final SendSmsRequest smsRequest;
        private final List<String> phoneNumbers = new ArrayList<>();
        private SMSTemplate.Key<T> templateKey;
        private T template;

        public AliyunSMSOptions(Client client, String signName) {
            this.client = client;
            this.smsRequest = new SendSmsRequest().setSignName(signName);
        }

        public AliyunSMSOptions<T> setPhoneNumber(String... phoneNumbers) {
            this.phoneNumbers.clear();
            return addPhoneNumber(phoneNumbers);
        }

        public AliyunSMSOptions<T> setPhoneNumber(Collection<String> phoneNumbers) {
            this.phoneNumbers.clear();
            return addPhoneNumber(phoneNumbers);
        }

        public AliyunSMSOptions<T> addPhoneNumber(String... phoneNumber) {
            this.phoneNumbers.addAll(Arrays.asList(phoneNumber));
            return this;
        }

        public AliyunSMSOptions<T> addPhoneNumber(Collection<String> phoneNumbers) {
            this.phoneNumbers.addAll(phoneNumbers);
            return this;
        }

        @SuppressWarnings("unchecked")
        public <NewT> AliyunSMSOptions<NewT> template(SMSTemplate.Key<NewT> key) {
            Assert.notNull(key, "SMSTemplate.Key can not be null!");
            this.templateKey = (SMSTemplate.Key<T>) key;
            this.template = (T) key.newTemplate();
            return (AliyunSMSOptions<NewT>) this;
        }

        public AliyunSMSOptions<T> variable(Consumer<T> templateConsumer) {
            templateConsumer.accept(template);
            return this;
        }

        @SneakyThrows
        public SendSmsResponse sendSms() {
            Assert.notEmpty(phoneNumbers, "phoneNumbers can not be empty!");
            smsRequest
                    .setTemplateCode(templateKey.getTemplateCode())
                    .setPhoneNumbers(StringUtils.collectionToCommaDelimitedString(phoneNumbers))
                    .setTemplateParam(JSONUtil.toJSONString(template));
            return client.sendSms(smsRequest);
        }
    }
}

这里的重点是,我们通过 SMSTemplate.Key<T> 传入了模板对象的类型信息之后,就可以通过一个通用的variable(Consumer<Template> templateConsumer)方法来设置模板的参数信息

这样一来,不管是作为组件默认内置的短信模板,还是某个服务自己特定的短信模板都一视同仁具有完善的类型提示和简便的调用方法

我们如果需要发送一个验证码短信:

    @Autowired
    AliyunSMSService aliyunSMSService;
    @Test
    void test() {
        aliyunSMSService
                .phoneNumber("12345678910")
                .template(SMSTemplates.AUTH_CODE)
                .variable(t -> t.setCode("123456"))
                .sendSms();
    }

如果是某些具有5-6个变量的短信模板,这种方法会大大简便调用:

/**
 * 工作流节点通知 短信模板
 */
@Data
@Accessors(chain = true)
public static class TaskSMS {
    /**
     * 用户姓名
     */
    private String name;
    /**
     * 流程所属的模块或系统
     */
    private String systemModule;
    /**
     * 流程名称
     */
    private String processName;
    /**
     * 任务名称
     */
    private String taskName;
    /**
     * 任务开始时间
     */
    private Date startTime;

    public String toString() {
        return "尊敬的用户 " + this.getName() + ",您有新的" + this.getTaskName() + "任务需完成," + this.getSystemModule() + "系统的" + this.getProcessName() + "流程已于" + DateUtil.format(this.getStartTime()) + "到达您的节点,请您及时处理!";
    }
}
/**
 * 工作流节点通知
 * <pre>{@code
 * 尊敬的用户 ${name},您有新的${taskName}任务需完成,${model}系统的${processName}流程已于${startTime}到达您的节点,请您及时处理!
 * }</pre>
 */
public static final SMSTemplate.Key<TaskSMS> BPM_TASK_SMS =
        SMSTemplate.key("工作流节点通知", "SMS_333333333", TaskSMS::new);
    @Autowired
    AliyunSMSService aliyunSMSService;
    @Test
    void test() {
        aliyunSMSService.phoneNumber(userEntity.getPhone())
                .template(BPM_TASK_SMS)
                .variable(t -> t.setName("testUser")
                                .setTaskName("testTask")
                                .setSystemModule("test")
                                .setProcessName("testProcess")
                                .setStartTime(new Date()))
                .sendSms();
    }

而且所有的短信模板都可以以静态变量的形式作为常量集中存放和调用,查看所有的短信模板只要利用IDE的提示就一目了然:

image.png