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 微信接口
企业微信相关配置如下
wechat:
cp:
corpid:
agentid:
corp:
secret:
- corpid 企业 ID
- agentid 应用 ID,在企业微信管理后台创建应用后,可以查看应用 ID
- corp.secret 应用的凭证密钥
weixin-java-tools 已经对微信接口进行了友好的封装,可以通过标签、部门等分组,查询用户标识。配置 bean 方法如下
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;
}
}
同时也提供了发送消息、已经封装消息的类
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();
}
}
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 页面
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "redirect:swagger-ui.html";
}
}
2.3 验权
两种验权方式
- 参数 +secret 通过 SHA 加密签名
- ip 白名单
通过 interceptor 实现
2.3.1 白名单校验
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 接口参数签名校验
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 加密工具封装
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 去掉验权操作。
@RestController
@RequestMapping("common")
@Anonymous
public class CommonController {
/**
* 获取系统时间,避免客户端时间不一致
*/
@GetMapping("current")
public Long current() {
return System.currentTimeMillis();
}
}
2.4 健康检测
定时用 http get 请求确认网络通畅,如果网络连接失败次数超过阈值,报警给系统管理员
通过 Scheduled 编写定时任务
@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 * * * * ?") // 每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) {
// alert to admin!
}
}
}
}
在配置 bean 中需要注入 bean 并允许启动调度
@Configuration
@EnableScheduling
public class WebConfig implements WebMvcConfigurer {
@Bean
public PingCheckTask pingCheckTask(){
return new PingCheckTask();
}
}
3. 其他
项目写的比较仓促,后续根据实际使用场景进行调整优化。都是站在巨人的肩膀上,利用现成的工具进行拼装。 如果有建议或者希望实现哪些功能,可以留言或者给我提 issue https://github.com/wangyuheng/pharos 。
春城无处不飞花,寒食东风御柳斜。
推荐大家吃 青团(艾团), 简直发现了新大陆
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于