本篇是发送短信的第二部分, 这里我们介绍一下如何限制向同一个用户(根据手机号和ip)发送短信的频率
使用session
如果是web程序, 那么在session中记录上次发送的时间也可以, 但是可以被绕过去. 最简单的, 直接重启浏览器 或者 清除cache等可以标记session的数据, 那么就可以绕过session中的记录. 虽然很多人都不是计算机专业的, 也没学过这些. 但是我们需要注意的是, 之所以限制发送频率, 是为了防止”短信炸弹”, 也就是有人恶意的频繁的请求向某个手机号码发送短信. 所以这个人是有可能懂得这些知识的.
下面我们使用”全局”的数据限制向同一个用户发送频率. 我们先做一些”准备”工作
定义接口、实体类
我们需要的实体类如下:
1 2 3 4 5 6 7 8 9 10
| public class SmsEntity{ private Integer id; private String mobile; private String ip; private Integer type; private Date time; private String captcha;
}
|
过滤接口如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public interface SmsFilter {
void init() throws Exception;
boolean filter(SmsEntity smsEntity);
void destroy();
}
|
主要代码
限制发送频率, 需要记录某个手机号(IP)及上次发送短信的时间. 很适合Map
去完成, 这里我们先使用ConcurrentMap
实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| public class FrequencyFilter implements SmsFilter {
private long sendInterval; private ConcurrentMap<String, Long> sendAddressMap = new ConcurrentHashMap<>();
@Override public boolean filter(SmsEntity smsEntity) { if(setSendTime(smsEntity.getMobile()) && setSendTime(smsEntity.getIp())){ return true; } return false; }
private boolean setSendTime(String id) { long currentTime = System.currentTimeMillis();
Long sendTime = sendAddressMap.putIfAbsent(id, currentTime); if(sendTime == null) { return true; }
long nextCanSendTime = sendTime + sendInterval; if(currentTime < nextCanSendTime) { return false; }
return sendAddressMap.replace(id, sendTime, currentTime); } }
|
这里, 主要的逻辑在setSendTime
方法中实现:
- 第25-28行: 首先假设用户是第一次发送短信, 那么应该把现在的时间放到
sendAddressMap
中. 如果putIfAbsent
返回null
, 那么说明用户确实是第一次发送短信, 而且现在的时间也已经放到了map中, 可以发送.
- 第30-33行: 如果用户不是第一次发送短信, 那么就需要判断上次发送短信的时间和现在的间隔是否小于发送时间间隔. 如果小于发送间隔, 那么不能发送.
- 第35行: 如果时间间隔足够大, 那么需要尝试着将发送时间设置为当前时间.
- 如果替换成功, 那么可以发送短信.
- 如果替换失败, 说明有另外一个线程在本线程执行26-35行之间已经进行了替换, 也就是说在刚才已经发送了一次短信.
- 那么可以再重复执行25-35行, 确保绝对正确.
- 也可以直接认为不能发送, 因为虽然理论上”执行26-35行”的时间可能大于”发送间隔”, 但是概率有多大呢? 基本上可以忽略了吧.
这段代码算是实现了频率的限制, 但是如果只有”入”而没有”出”那么sendAddressMap
占用的内容会越来越大, 直到产生OutOfMemoryError
异常. 下面我们再添加代码定时清理过期的数据.
清理过期数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
|
public class FrequencyFilter implements SmsFilter { private long cleanMapInterval; private Timer timer = new Timer("sms_frequency_filter_clear_data_thread");
@Override public void init() { timer.schedule(new TimerTask() { @Override public void run() { cleanSendAddressMap(); } }, cleanMapInterval, cleanMapInterval); }
private void cleanSendAddressMap() { long currentTime = System.currentTimeMillis(); long expireSendTime = currentTime - sendInterval;
for(String key : sendAddressMap.keySet()) { Long sendTime = sendAddressMap.get(key); if(sendTime < expireSendTime) { sendAddressMap.remove(key, sendTime); } } }
@Override public void destroy() { timer.cancel(); } }
|
这段程序不算复杂, 启动一个定时器, 每隔cleanMapInterval
毫秒执行一次cleanSendAddressMap
方法清理过期数据.
cleanSendAddressMap
方法中首先获取当前时间, 根据当前时间获得一个时间值: 所有在这个时间之后发送短信的, 现在不可以再次发送短信. 然后从整个map中删除所有value小于这个时间值的键值对.
当然, 添加上面的代码后, 最开始的代码又有bug了: 当最后一行sendAddressMap.replace(id, sendTime, currentTime)
执行失败时不一定是其他线程进行了替换, 也有可能是清理线程把数据删了. 所以我们需要修改setSendTime
方法最后几行:
1 2 3 4 5 6 7
| private boolean setSendTime(String id) { if(sendAddressMap.replace(id, sendTime, currentTime)) { return true; } return sendAddressMap.putIfAbsent(id, currentTime) == null; }
|
- 这里如果替换成功, 那么直接返回true.
- 如果替换不成功. 那么可能是其他线程先替换了(第一种情况); 也可能是被清理线程删除了(第二种情况); 甚至可以能是先被清理线程删除了, 又有其他线程插入了新的时间值(第三种情况).
- 如果是第一种情况 或者 第三种情况, 那么情况和最开始分析的一样, 可以直接认为不能发送.
- 如果是第二种情况, 那么应该是可以发送的.
- 为了确认是哪种情况, 我们可以执行一次
putIfAbsent
, 如果成功, 说明是第二种情况, 可以发送; 否则是第一种或者第三种情况, 不能发送.
至此, 限制发送时间的代码就算是完成了. 当然, 这段程序还有一个小bug或者说”特性”:
假如, IP为”192.168.0.1”的客户请求向手机号”12345678900”发送短信, 然后在sendInterval
之内又在IP为”192.168.0.2”的机器上请求向手机号”12345678900”发送短信. 那么短信将不会发出去, 而且手机号”12345678900”的上次发送时间被置为当前时间.
使用实例
下面我们提供一个Server层, 展示如何将上一篇以及这一篇中的代码整合到一起:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| public class SmsService{ private Sms sms; private List<SmsFilter> filters; private Properties template;
public int sendCaptcha(SmsEntity smsEntity){ for(SmsFilter filter : filters) { if(!filter.filter(smsEntity)){ return 1; } } if(SmsEntity.REGISTER_TYPE.equals(smsEntity.getType())) { sendRegisterSms(smsEntity); } else{ return 2; } return 0; }
private void sendRegisterSms(SmsEntity smsEntity) { sms.sendMessage(smsEntity.getMobile(), template.getProperty("register").replace("{captcha}", smsEntity.getCaptcha())); }
}
|
之后将FrequencyFilter
以及上一篇中的AsyncSmsImpl
通过set方法”注入”进去即可.
最后, 点击这里下载代码.
发送短信文章: