1. 项目背景
朋友在一家日企,运维工具老旧,希望增加一款系统告警工具。我提议通过微信企业号(改版为企业微信)或者短信推送告警信息。提出两点要求:
- 可以群发给企业微信中的多个人
- 监测健康检测服务是否存活(绕嘴。。)
做了第一版demo,定位为通信渠道的http代理。贴出企业微信相关代码,如果有需要的同学可以拿去用,记得点个star就好。
https://github.com/wangyuheng/pharos
1.1 依赖项目
因为定位为http代理,并未使用数据库及持久化工具。
- springboot
- 企业微信接口 https://work.weixin.qq.com/api/doc
- 可能是目前最好最全的微信Java开发工具包(SDK)https://github.com/Wechat-Group/weixin-java-tools
- swagger 工具 https://github.com/wangyuheng/spring-boot-swagger-starter
2. 项目code
代理入口为健康检测工具,出口为企业微信、短信等。同时需要保障和健康检测工具之间的网络通畅。
最近在尝试画图
2.1 微信接口
企业微信相关配置如下
1 2 3 4 5 6
| wechat: cp: corpid: agentid: corp: secret:
|
- corpid 企业ID
- agentid 应用ID,在企业微信管理后台创建应用后,可以查看应用ID
- corp.secret 应用的凭证密钥
weixin-java-tools已经对微信接口进行了友好的封装,可以通过标签、部门等分组,查询用户标识。配置bean方法如下
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| package com.crick.business.pharos.config;
import me.chanjar.weixin.cp.api.WxCpService; import me.chanjar.weixin.cp.api.impl.WxCpDepartmentServiceImpl; import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl; import me.chanjar.weixin.cp.api.impl.WxCpTagServiceImpl; import me.chanjar.weixin.cp.api.impl.WxCpUserServiceImpl; import me.chanjar.weixin.cp.config.WxCpConfigStorage; import me.chanjar.weixin.cp.config.WxCpInMemoryConfigStorage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;
@Configuration @ConditionalOnClass(WxCpService.class) public class WechatCpConfig {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${wechat.cp.corpid}") private String corpid; @Value("${wechat.cp.corp.secret}") private String corpSecret; @Value("${wechat.cp.agentid}") private Integer agentid;
@Bean @ConditionalOnMissingBean public WxCpConfigStorage configStorage() { WxCpInMemoryConfigStorage configStorage = new WxCpInMemoryConfigStorage(); logger.info("****************wechat properties start****************"); logger.info("corpid:{}", corpid); logger.info("corpSecret:{}", corpSecret); logger.info("agentid:{}", agentid); logger.info("****************wechat properties end****************"); configStorage.setCorpId(corpid); configStorage.setCorpSecret(corpSecret); configStorage.setAgentId(agentid); return configStorage; }
@Bean @ConditionalOnMissingBean public WxCpService WxCpService(WxCpConfigStorage configStorage) { WxCpService wxCpService = new WxCpServiceImpl(); wxCpService.setWxCpConfigStorage(configStorage); wxCpService.setTagService(new WxCpTagServiceImpl(wxCpService)); wxCpService.setDepartmentService(new WxCpDepartmentServiceImpl(wxCpService)); wxCpService.setUserService(new WxCpUserServiceImpl(wxCpService)); return wxCpService; }
}
|
同时也提供了发送消息、已经封装消息的类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| package com.crick.business.pharos.service;
import me.chanjar.weixin.cp.bean.WxCpMessage;
public class AlertTextBuilder {
private Integer agentid;
public AlertTextBuilder(Integer agentid) { this.agentid = agentid; }
public WxCpMessage buildForTag(String content, String tag) { return WxCpMessage.TEXT().agentId(agentid).content(content).toTag(tag).build(); }
public WxCpMessage buildForUsers(String content, String users) { return WxCpMessage.TEXT().agentId(agentid).content(content).toUser(users).build(); }
}
|
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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| package com.crick.business.pharos.service;
import me.chanjar.weixin.common.exception.WxErrorException; import me.chanjar.weixin.cp.api.WxCpService; import me.chanjar.weixin.cp.bean.WxCpUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service;
import java.util.List; import java.util.stream.Collectors;
@Service public class WechatAlertService implements AlertService {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired private WxCpService wxCpService; @Autowired private AlertTextBuilder alertTextBuilder;
@Override public void alertTextToTag(String content, String tag) { try { wxCpService.messageSend(alertTextBuilder.buildForTag(content, tag)); } catch (WxErrorException e) { logger.error("alertTextToTag error! tag:{}", tag, e); } }
@Override public void alertTextToUsers(String content, List<String> users) { try { wxCpService.messageSend(alertTextBuilder.buildForUsers(content, String.join(",", users))); } catch (WxErrorException e) { logger.error("alertTextToUsers error! users:{}", users, e); } }
@Override public void alertTextToDepartment(String content, Integer department) { try { List<WxCpUser> wxCpUserList = wxCpService.getUserService().listSimpleByDepartment(department, true, 0); if (null != wxCpUserList) { String userList = wxCpUserList.stream() .map(WxCpUser::getUserId) .collect(Collectors.joining(",")); wxCpService.messageSend(alertTextBuilder.buildForUsers(content, userList)); } } catch (WxErrorException e) { logger.error("alertTextToDepartment error! department:{}", department, e); } }
}
|
2.2 restful接口
对外暴露的接口主要提供两个功能
- 代理企业微信,用于查询部门、标签、userId等
- 发送告警信息
具体实现在service中,restful暴露了http请求接口及swagger接口。并且将首页指向了swagger页面
1 2 3 4 5 6 7 8 9
| @Controller public class IndexController { @GetMapping("/") public String index() { return "redirect:swagger-ui.html"; } }
|
2.3 验权
两种验权方式
- 参数+secret通过SHA加密签名
- ip白名单
通过interceptor实现
2.3.1 白名单校验
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 AuthorInterceptor extends HandlerInterceptorAdapter {
@Value("#{'${white.list}'.split(',')}") private List<String> whiteList;
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) { return true; } } String clientIp = getIpAddress(request); if (!whiteList.contains(clientIp)) { throw new RestfulException("client ip: " + clientIp + " not in white list", RestfulErrorCode.AUTHOR_ERROR); } return super.preHandle(request, response, handler); }
private String getIpAddress(HttpServletRequest request) { String ip = request.getHeader("x-forwarded-for"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } }
|
2.3.2 接口参数签名校验
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 SignInterceptor extends HandlerInterceptorAdapter {
@Value("${secret.key}") public String secretKey;
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) { return true; } }
String sign = request.getParameter("sign"); if (null == sign || "".equals(sign)) { throw new RestfulException("must have a sign param!", RestfulErrorCode.SIGN_ERROR); } else { Map<String, String[]> parameters = request.getParameterMap(); if (parameters.size() > 0) { StringBuilder sb = new StringBuilder(); for (String key : parameters.keySet()) { if ("sign".equals(key)) { continue; } sb.append(key).append("-").append(Arrays.toString(parameters.get(key))).append("-"); } sb.append("token").append("-").append(secretKey); if (!sign.equals(EncryptUtil.sha1(sb.toString()))) { throw new RestfulException("sign check fail!", RestfulErrorCode.SIGN_ERROR); } } } return super.preHandle(request, response, handler); }
}
|
SHA加密工具封装
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
| public class EncryptUtil {
private EncryptUtil() { }
private static final String SHA_1_ALGORITHM = "SHA-1"; private static final String SHA_256_ALGORITHM = "SHA-256";
public static String sha1(String source) { return sha(source, SHA_1_ALGORITHM); }
public static String sha256(String source) { return sha(source, SHA_256_ALGORITHM); }
private static String sha(String source, String instance) { MessageDigest md; try { md = MessageDigest.getInstance(instance); md.update(source.getBytes()); return new String(Hex.encodeHex(md.digest())); } catch (NoSuchAlgorithmException e) { return null; } } }
|
如果需要对sign有效期进行校验,需要提供获取服务器时钟的方法,避免因为服务器时间不一致导致的时间差, 此方法可以通过@Anonymous去掉验权操作。
1 2 3 4 5 6 7 8 9 10 11 12 13
| @RestController @RequestMapping("common") @Anonymous public class CommonController {
@GetMapping("current") public Long current() { return System.currentTimeMillis(); } }
|
2.4 健康检测
定时用http get 请求确认网络通畅,如果网络连接失败次数超过阈值,报警给系统管理员
通过Scheduled编写定时任务
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
| @Component public class PingCheckTask { private static OkHttpClient okHttpClient = new OkHttpClient();
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${ping.service.url}") private String serviceUrl; @Value("${ping.period:3}") private int period;
private static Map<String, Integer> errorCalculate = new ConcurrentHashMap<>();
private void resetCount(String url) { errorCalculate.put(url, 0); }
private int pushCount(String url) { errorCalculate.put(url, errorCalculate.getOrDefault(url, 0) + 1); return errorCalculate.get(url); }
@Scheduled(cron = "0/20 * * * * ?") public void scheduler() throws IOException { Request request = new Request.Builder() .url(serviceUrl) .build(); Response response = okHttpClient.newCall(request).execute(); if (response.isSuccessful()) { logger.info("ping check {} success!", serviceUrl); resetCount(serviceUrl); } else { logger.info("ping check {} fail! response:{}", serviceUrl, response); int count = pushCount(serviceUrl); if (count > period) { } } } }
|
在配置bean中需要注入bean并允许启动调度
1 2 3 4 5 6 7 8 9
| @Configuration @EnableScheduling public class WebConfig implements WebMvcConfigurer {
@Bean public PingCheckTask pingCheckTask(){ return new PingCheckTask(); } }
|
3. 其他
项目写的比较仓促,后续根据实际使用场景进行调整优化。都是站在巨人的肩膀上,利用现成的工具进行拼装。 如果有建议或者希望实现哪些功能,可以留言或者给我提issue https://github.com/wangyuheng/pharos 。
春城无处不飞花,寒食东风御柳斜。
推荐大家吃 青团(艾团), 简直发现了新大陆