介绍
服务端采集嵌入式设备发送的报文信息,做入库,部分报文处理后推送设备,校时推送,及后期扩展的业务操作。
简单说明
- 嵌入式设备和服务端采用websocket的通信方式。为什么选用websocket?我觉得主要原因是由于websockt的两个特点,第一是长连接,第二是服务端可以主动向客户端发起推送。实际的业务场景也正是设备需要一直向服务端发送数据,而服务端需要判断某些报文后来做推送。
- 设备首先要经过认证服务
- 服务端目前分为两块,数据采集服务和数据处理服务。
数据解析服务主要的作用是接收设备侧的报文,生产至kafka。从kafka消费需要推送的报文,推送至设备。 - 为什么要做两个服务,中间再加一层kafka。首先将采集和处理分开能更利于以后的扩展开发,符合解耦的设计,其次考虑到公司内部其他服务也需要获取这部分数据时,可以直接和kafka对接。我理解的也是扩展把。
服务架构
业务流程说明
- 设备向认证服务发送post请求,携带设备认证信息。
- 认证服务校验设备信息正确否,不正确直接拒绝。正确则判断有哪些可用的数据采集服务,随机产生一个可用的采集网关信息。采集网关启动时会向认证服务发送其ip,port,path。
- 认证服务拿到可达的采集网关服务信息后再生产一个token一块给设备返回
- 设备拿着获取到的采集网关信息发送websocket请求,
- 采集网关接收到请求后会向认证服务发送token,ip,path
- 认证服务校验token,ip,port的一致性。这里之所以要再次向认证服务校验是来确保认设备只能连接证服务指定的采集网关服务。发送服务需要在建立连接之前。
- 响应给采集网关校验结果
- 结果ok,设备向采集网关发送报文,简单处理后发送至kafka
- 解析服务处理 进行一系列业务处理
- 将需要响应的报文发送至kafka的另一个topic
- 采集网关监听到kafka数据口推送至指定的session
技术点或实现思路
如何服务启动发送请求?
package com.ptl.gatewaydatacollection.rest;
import com.bbap.feignServer.feign.FeignServer;
import com.bbap.feignServer.models.FeignRequest;
import com.bbap.feignServer.models.FeignResp;
import com.bsa.bbap.log.BbapLog;
import com.ptl.gatewaydatacollection.models.GatewayInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.net.Inet4Address;
/**
* @Description 数据采集网关启动后向认证服务发送配置信息
* @Author twt
* @Version V1.0.0
* @Since 1.0
* @Date 2019/10/15
*/
@Component
public class StartService implements CommandLineRunner {
//@Autowired
//private AuthFeign authFeign;
@Autowired
private FeignServer feignServer;
@Value("${server.port}")
private String port;
@Value("${info.service.name}")
private String serviceName;
@Autowired
private BbapLog log;
@Autowired
private AuthFeignService authFeignService;
@Override
public void run(String... strings) {
System.out.println("数据采集网关启动后执行。。。");
log.debug("数据采集网关启动后执行。。。");
try {
String hostAddress = Inet4Address.getLocalHost().getHostAddress();
System.out.println("ip:" + hostAddress);
System.out.println("port:" + port);
GatewayInfo gatewayInfo = new GatewayInfo();
gatewayInfo.setService_name(serviceName);
gatewayInfo.setIp(hostAddress);
gatewayInfo.setPort(port);
gatewayInfo.setPath("/websocket");
FeignRequest feignRequest = new FeignRequest();
feignRequest.setService_name(serviceName);
feignRequest.setIp(hostAddress);
feignRequest.setPort(port);
feignRequest.setPath("/websocket");
FeignResp feignResp = feignServer.sendGateWayInfo(feignRequest);
System.out.println("返回状态:"+feignResp.getStatus());
System.out.println("返回信息:"+feignResp.getMsg());
log.info("返回状态:"+feignResp.getStatus());
log.info("返回信息:"+feignResp.getMsg());
} catch (Exception e) {
log.error("数据采集网关配置信息发送失败",e);
System.out.println("数据采集网关配置信息发送失败");
e.printStackTrace();
}
}
}
- 发送请求是通过springcloud中的feign组件来实现,这里对feign组件做了简单封装
采集网关可能有很多节点,如何选择一个可达的节点?
/**
* 随机选择所有网关节点中的一个可达节点
*
* @param gatewayInfos
* @return
*/
private GatewayInfo getGatewayinfo(List<GatewayInfo> gatewayInfos) {
int size = gatewayInfos.size();
int nextInt = new Random().nextInt(size);//产生0到size-1的随机索引
GatewayInfo gatewayInfo = gatewayInfos.get(nextInt);
while (!util.isSurvive(gatewayInfo.getIp(), gatewayInfo.getPort())) {//判断随机的网关ip,port可达否
gatewayInfos.remove(nextInt);
return gatewayInfos.size() > 0 ? getGatewayinfo(gatewayInfos) : null;
}
return gatewayInfo;
}
- 这里提供一个随机的思路,当然也有轮询
/**
* 指定ip和端口是否可达
*
* @param ip
* @param port
* @return
*/
public boolean isSurvive(String ip, String port) {
Socket socket = new Socket();
try {
socket.connect(new InetSocketAddress(ip, Integer.parseInt(port)), 3000);
} catch (SocketTimeoutException s) {
log.error("指定的ip和端口不可达", s);
s.printStackTrace();
return false;
} catch (IOException e) {
log.error("指定的ip和端口不可达", e);
e.printStackTrace();
return false;
} catch (Exception e) {
log.error("指定的ip和端口不可达", e);
e.printStackTrace();
return false;
} finally {
try {
if (socket != null) {
socket.close();
}
}
catch (IOException e) {
log.error("关闭socket异常", e);
e.printStackTrace();
}
catch (Exception e) {
log.error("关闭socket异常", e);
e.printStackTrace();
}
}
return true;
}
- springcloud中有健康度检查的接口,试过但是不太好用,应该是没找到正确的姿势,有用过的小伙伴可以分享哈
websockt服务端如何开发?客户端可以使用在线测试工具
package com.ptl.gatewaydatacollection.websocket;
import com.ptl.gatewaydatacollection.interceptor.AnnualInspectionInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.HandshakeInterceptor;
/**
* @Description
* @Author twt
* @Version V1.0.0
* @Since 1.0
* @Date 2019/10/16
*/
@Configuration
@EnableWebMvc
@EnableWebSocket
public class WebsocketConfig extends WebMvcConfigurerAdapter implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry.addHandler(myHandler(), "/websocket").addInterceptors(interceptor())
.setAllowedOrigins("*");
}
@Bean
public WebSocketHandler myHandler() {
return new AnnualInspectionHandler();
}
@Bean
public HandshakeInterceptor interceptor() {
return new AnnualInspectionInterceptor();
}
}
- 如果有多个websocket请求,可以多次调用addHandler和addInterceptors,拦截器不是必须项,setAllowedOrigins设置允许访问的来源,个人理解是同源策略问题
package com.ptl.gatewaydatacollection.interceptor;
import com.bbap.feignServer.models.FeignResp;
import com.bsa.bbap.log.BbapLog;
import com.google.gson.Gson;
import com.ptl.gatewaydatacollection.rest.AuthFeignService;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* @Description
* @Author twt
* @Version V1.0.0
* @Since 1.0
* @Date 2019/10/16
*/
public class AnnualInspectionInterceptor extends HttpSessionHandshakeInterceptor {
//@Autowired
//AuthFeign authFeign;
@Autowired
private AuthFeignService authFeignService;
@Autowired
private BbapLog log;
@Autowired
private Gson gson;
/**
* 握手之前调用 校验token,ip,port
* @param request
* @param response
* @param wsHandler
* @param attributes
* @return
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler
wsHandler, Map<String, Object> attributes) {
System.out.println("连接前进行处理...");
log.debug("连接前进行处理...");
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String token = servletRequest.getHeader("token");
//token="536d81d3-b556-4354-9117-5b631395a8bd";
if (StringUtils.isEmpty(token)){
System.out.println("token为空...");
log.info("token为空...");
return false;
}
FeignResp result = authFeignService.checkTokenAndIPPort(token);
boolean status = result.getStatus();
System.out.println("token校验状态:"+status);
log.info("token校验状态:"+status);
attributes.put("eToken",token);//TODO 替换为token,该设备的本次请求对应的token
return status;
//return super.beforeHandshake(request, response, wsHandler, attributes);
}
/**
* 握手后调用
* @param request
* @param response
* @param wsHandler
* @param ex
*/
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception ex) {
System.out.println("连接后进行处理...");
log.debug("连接后进行处理...");
super.afterHandshake(request, response, wsHandler, ex);
}
}
- 拦截器,建立连接前可以自定义一些操作,简单可以理解为aop的前置处理器,也可以实现HandshakeInterceptor接口,不过子类肯定更强大点
package com.ptl.gatewaydatacollection.websocket;
import com.bsa.bbap.log.BbapLog;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.ptl.gatewaydatacollection.models.WebSocketMsg;
import com.ptl.gatewaydatacollection.utils.KafkaSend;
import org.apache.commons.lang.StringUtils;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Description
* @Author twt
* @Version V1.0.0
* @Since 1.0
* @Date 2019/10/16
*/
public class AnnualInspectionHandler extends TextWebSocketHandler {
@Autowired
private Gson gson;
private static AtomicInteger onlineCount = new AtomicInteger(0);//统计session在线数量
//token@sessionID和session对应,每次新连接的token有可能一样,不同节点的sessionID有可能一样
private static final Map<String, WebSocketSession> tokenSessionIDToSession = new ConcurrentHashMap<>();
@Autowired
private KafkaSend kafkaSend;
@Autowired
private BbapLog log;
/**
* 推送处理后的消息至设备
*
* @param record
*/
@KafkaListener(topics = "${topic.processedQueue}")
public void pushProcessedData(ConsumerRecord<?, ?> record) {
Optional<?> kafkaMessage = Optional.ofNullable(record.value());
if (kafkaMessage.isPresent()) {
Object message = kafkaMessage.get();
String pushMsg = message.toString();
System.out.println("接收到需要推送的消息:" + pushMsg);
log.debug("接收到需要推送的消息:" + pushMsg);
Map<String, WebSocketMsg> socketMsgMap = gson.fromJson(pushMsg, new TypeToken<Map<String, WebSocketMsg>>() {
}.getType());
Set<Map.Entry<String, WebSocketMsg>> entrySet = socketMsgMap.entrySet();
for (Map.Entry<String, WebSocketMsg> msgEntry : entrySet) {
String tokenSessionID = msgEntry.getKey();
WebSocketMsg responseMsg = msgEntry.getValue();
String pushToSession = gson.toJson(responseMsg);
//根据kafka的tokenSessionID推送到指定的session
if (tokenSessionIDToSession.containsKey(tokenSessionID)) {
WebSocketSession session = tokenSessionIDToSession.get(tokenSessionID);
pushMsgToOnlySession(session, pushToSession);
}
//校时推送至所有设备
if ("checkTimeToAllSession".equals(tokenSessionID)) {
pushMsgToAllDevice(pushToSession);
}
}
} else {
log.debug("要推送的消息为空...");
System.out.println("要推送的消息为空...");
}
}
/**
* 推送消息至所有设备session
*
* @param msg
*/
private void pushMsgToAllDevice(String msg) {
Set<Map.Entry<String, WebSocketSession>> entries = tokenSessionIDToSession.entrySet();
for (Map.Entry<String, WebSocketSession> entry : entries) {
WebSocketSession session = entry.getValue();
try {
session.sendMessage(new TextMessage(msg));
log.debug("推送" + msg + "至所有设备session成功");
System.out.println("推送" + msg + "至所有设备session成功");
} catch (IOException e) {
log.debug("推送" + msg + "至所有设备session失败");
log.error("推送失败",e);
System.out.println("推送" + msg + "至所有设备session失败");
e.printStackTrace();
}
}
}
/**
* 推送消息至指定session
*
* @param socketSession
* @param msg
*/
private void pushMsgToOnlySession(WebSocketSession socketSession, String msg) {
try {
if (socketSession != null) {
socketSession.sendMessage(new TextMessage(msg));
log.debug("推送" + msg + "成功");
System.out.println("推送" + msg + "成功");
}
} catch (IOException e) {
log.debug("推送" + msg + "成功");
log.error("推送失败",e);
System.out.println("推送" + msg + "成功");
e.printStackTrace();
}
}
/**
* 建立连接
*
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.debug("建立连接...");
System.out.println("建立连接...");
String eToken = session.getAttributes().get("eToken").toString();
if (StringUtils.isNotBlank(eToken)) {
tokenSessionIDToSession.put(eToken + "@" + session.getId(), session);
int onlineNum = addOnlineCount();
log.debug("连接建立,当前在线数:" + onlineNum);
System.out.println("连接建立,当前在线数:" + onlineNum);
System.out.println(tokenSessionIDToSession);
}
/* while (true){
session.sendMessage(new TextMessage("123"));
Thread.sleep(3000);
}*/
super.afterConnectionEstablished(session);
}
/**
* 接收数据 发送至kafka
*
* @param session
* @param message
* @throws Exception
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String eData = message.getPayload();
String eToken = session.getAttributes().get("eToken").toString();
Map<String, WebSocketMsg> socketMsgMap = new HashMap<>();
log.debug("接收到设备数据:" + eData);
System.out.println("接收到设备数据:" + eData);
WebSocketMsg socketMsg = gson.fromJson(eData, WebSocketMsg.class);
String receiveData = gson.toJson(socketMsg);
log.debug("转换后的数据:" + receiveData);
System.out.println("转换后的数据:" + receiveData);
socketMsgMap.put(eToken + "@" + session.getId(), socketMsg);//将接收的消息和eToken+"@"+sessionID绑定,因为数据采集网关某个节点可能接入多台设备
kafkaSend.sendToOriginQueue(gson.toJson(socketMsgMap));//将绑定后的消息放入kafka
}
/**
* 连接关闭
*
* @param session
* @param status
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.debug("连接关闭...");
System.out.println("连接关闭...");
String eToken = session.getAttributes().get("eToken").toString();
System.out.println(eToken);
tokenSessionIDToSession.remove(eToken + "@" + session.getId());
int onlineNum = subOnlineCount();
log.debug("关闭连接,当前在线数:" + onlineNum);
System.out.println("关闭连接,当前在线数:" + onlineNum);
log.info(status.toString());
super.afterConnectionClosed(session, status);
}
/**
* 传输中断
*
* @param session
* @param exception
* @throws Exception
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.debug("传输中断...");
System.out.println("传输中断...");
String eToken = session.getAttributes().get("eToken").toString();
tokenSessionIDToSession.remove(eToken + "@" + session.getId());
int onlineNum = subOnlineCount();
log.debug("中断传输,当前在线数:" + onlineNum);
System.out.println("中断传输,当前在线数:" + onlineNum);
log.info(exception.toString());
super.handleTransportError(session, exception);
/*if (session.isOpen()) {
session.close();
}*/
}
@Override
public boolean supportsPartialMessages() {
return super.supportsPartialMessages();
}
private int addOnlineCount() {
return onlineCount.incrementAndGet() < 0 ? 0 : onlineCount.get();//在线数不能为负数
}
private int subOnlineCount() {
return onlineCount.decrementAndGet() < 0 ? 0 : onlineCount.get();//在线数不能为负数
}
}
- handleTextMessage方法中session指本次会话,message为接收到的数据。推送调用session的sendMessage方法就可以向客户端发送数据
策略模式的使用
- 报文有action字段,每个报文对应的action不一样,需要根据不同的报文来进行不同的业务处理,所以需要定义一个数据结构可以来接收所有不同类型的报文
接收报文的数据结构
package com.ptl.gatewaydataanalysis.models.websocket;
public class WebSocketMsg<T> {
private Integer type;// 表示消息类型
private String id;// 请求唯一 id,格式:其中 ‘xxx’为自增数值从 001~999,且不需要随时间变化而重置,
private Integer action;// 请求含义
private String des;// 信息描述
private T data;// 详细数据 响应数据也存在这里
public Integer getType() {
return type;
}
public void setType(Integer type) {
this.type = type;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Integer getAction() {
return action;
}
public void setAction(Integer action) {
this.action = action;
}
public String getDes() {
return des;
}
public void setDes(String des) {
this.des = des;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
不同的报文可以进入不同的业务类
- 定义一个接口,每个业务类来实现
package com.ptl.gatewaydataanalysis.action;
import com.ptl.gatewaydataanalysis.models.websocket.WebSocketMsg;
public interface MsgAction {
/**
* 处理websocket收到的数据,返回结果给客户端设备
* @param sessionIdToken
* @param webSocketMsg
* @return
*/
WebSocketMsg call(String sessionIdToken,String webSocketMsg);
}
- ApplicationContext ,可以根据beanName来获取对象
package com.ptl.gatewaydataanalysis.utils;
import com.ptl.gatewaydataanalysis.action.MsgAction;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
if (SpringUtil.applicationContext == null) {
SpringUtil.applicationContext = applicationContext;
}
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
public static Object getBean(String name) {
return getApplicationContext().getBean(name);
}
/**
* 通过action的字段值,获取相应的处理bean
*
* @param name
* @return
*/
public static MsgAction getBeanToMsgAction(String name) {
try {
MsgAction msg = (MsgAction) getApplicationContext().getBean(name);
return msg;
} catch (Exception e) {
return null;
}
}
public static <T> T getBean(Class<T> clazz) {
return getApplicationContext().getBean(clazz);
}
public static <T> T getBean(String name, Class<T> clazz) {
return getApplicationContext().getBean(name, clazz);
}
}
- Action为定义的枚举,建立报文中action和beanName(chargeAndDisChargeProAction)的对应关系。被@Component(“chargeAndDisChargeProAction”)修饰的类(业务类)实现MsgAction接口。这样就可以根据不同的报文处理不同的业务
WebSocketMsg socketMsg = entry.getValue();//报文数据
String action = Action.getAction(socketMsg.getAction()).getDescript();
String beanName = action == null ? "errorAction" : action;//action不存在,引入ErrorAction类
MsgAction msgAction = SpringUtil.getBeanToMsgAction(beanName);
if (msgAction != null) {
WebSocketMsg responseMsg = msgAction.call(sessionIdToken, gson.toJson(socketMsg));//根据action来处理不同的业务
}
kafka分片
- 这次需求考虑到kafka可能需要分片来来提高数据吞吐量,但是由于目前数据解析服务是单击环境,而且分片后无法保证报文传输的顺序。所以使用默认的一个topic对应一个partition。
代码
思考
- 关于websocket,kafka的使用,欢迎大家提建议