接上一節:springboot+mina框架服務端的實現(一) ------ pom依賴、mina配置類、自定義協議以及編解碼器的實現
參考博客:矢落葉の博客
四、 心跳包的實現
先簡單介紹下keepAlive的機制:
首先,需要搞清楚TCP keepalive是幹什麼用的。從名字理解就能夠知道,keepalive就是用來檢測一個tcp connection是否還連接正常。當一個tcpconnection建立好之後,如果雙方都不發送數據的話,tcp協議本身是不會發送其它的任何數據的,也就是說,在一個idle的connection上,兩個socket之間不產生任何的數據交換。從另一個方面講,當一個connection建立之後,鏈接雙方可以長時間的不發送任何數據,比如幾天,幾星期甚至幾個月,但該connection仍然存在。
所以,這就可能出現一個問題。舉例來說,server和client建立了一個connection,server負責接收client的request。當connection建立好之後,client由於某種原因機器停機了。但server端並不知道,所以server就會一直監聽着這個connection,但其實這個connection已經失效了。
keepalive就是爲這樣的場景準備的。當把一個socket設置成了keepalive,那麼這個socket空閒一段時間後,它就會向對方發送數據來確認對方仍然存在。放在上面的例子中,如果client停機了,那麼server所發送的keepalive數據就不會有response,這樣server就能夠確認client完蛋了(至少從表面上看是這樣)。
MINA本身提供了一個過濾器類: org.apache.mina.filter.keepalive.KeepAliveFilter ,該過濾器用於在IO空閒的時候發送並且反饋心跳包(keep-alive request/response)。
該類構造函數中參數有三個分別是:
(1)KeepAvlieMessageFactory: 該實例引用用於判斷接受與發送的包是否是心跳包,以及心跳請求包的實現
(2)IdleStatus: 該過濾器所關注的空閒狀態,默認認爲讀取空閒。 即當讀取通道空閒的時候發送心跳包
(3)KeepAliveRequestTimeoutHandler: 心跳包請求後超時無反饋情
先看KeepAliveMessageFactory
接口:
public interface KeepAliveMessageFactory {
boolean isRequest(IoSession session, Object message);
boolean isResponse(IoSession session, Object message);
Object getRequest(IoSession session);
Object getResponse(IoSession session, Object request);
}
實現這個接口:
public class KeepAliveFactoryImpl implements KeepAliveMessageFactory {
static final Logger logger = LoggerFactory.getLogger(KeepAliveFactoryImpl.class);
@Resource
private BaseHandler keepAliveHandler;
// 用來判斷接收到的消息是不是一個心跳請求包,是就返回true[接收端使用]
@Override
public boolean isRequest(IoSession session, Object message) {
if (message instanceof MyPack) {
MyPack pack = (MyPack) message;
if (Const.HEART_BEAT == pack.getModule()) {
return true;
}
}
return false;
}
// 用來判斷接收到的消息是不是一個心跳回復包,是就返回true[發送端使用]
@Override
public boolean isResponse(IoSession session, Object message) {
// TODO Auto-generated method stub
return false;
}
// 在需要發送心跳時,用來獲取一個心跳請求包[發送端使用]
@Override
public Object getRequest(IoSession session) {
// TODO Auto-generated method stub
return null;
}
// 在需要回復心跳時,用來獲取一個心跳回復包[接收端使用]
@Override
public Object getResponse(IoSession session, Object request) {
MyPack attendPack = (MyPack) request;
if (null == session.getAttribute(Const.SESSION_KEY)) {
// 需要先進行登錄
return new MyPack(Const.AUTHEN, attendPack.getSeq(), "fail");
}
// 將超時次數置爲0
session.setAttribute(Const.TIME_OUT_KEY, 0);
return new MyPack(Const.HEART_BEAT, attendPack.getSeq(), "success");
}
}
超時後的處理放在業務處理類的sessionIdle
方法中實現,稍後詳細介紹
五、 自定義Session類及其管理類
方便對session會話進行管理,方便對session會話集合獲取和刪除
服務端接收到新的Session後,構造一個封裝類,實現session 的部分方法,並額外實現方法
5.1 MySession 類
就一個處理業務的方法,使用自定義Session代替IoSession
public class MySession implements Serializable {
private static final long serialVersionUID = 1L;
// 不參與序列化
private transient IoSession session;
// session在本機器的ID
private Long nid;
// session綁定的服務ip
private String host;
// 訪問端口
private int port;
// session綁定的設備
private String account;
public MySession() {
}
public MySession(IoSession session) {
this.session = session;
this.host = ((InetSocketAddress) session.getRemoteAddress()).getAddress().getHostAddress();
this.port = ((InetSocketAddress) session.getRemoteAddress()).getPort();
this.nid = session.getId();
}
/**
* 將key-value自定義屬性,存儲到IO會話中
*/
public void setAttribute(String key, Object value) {
if (null != session) {
session.setAttribute(key, value);
}
}
/**
* 從IO的會話中,獲取key的value
*/
public Object getAttribute(String key) {
if (null != session) {
return session.getAttribute(key);
}
return null;
}
/**
* 在IO的會話中,判斷是否存在包含key-value
*/
public boolean containsAttribute(String key) {
if (null != session) {
return session.containsAttribute(key);
}
return false;
}
/**
* 從IO的會話中,刪除key
*/
public void removeAttribute(String key) {
if (null != session) {
session.removeAttribute(key);
}
}
/**
* 獲取IP地址
*/
public SocketAddress getRemoteAddress() {
if (null != session) {
return session.getRemoteAddress();
}
return null;
}
/**
* 將消息對象 message發送到當前連接的對等體(異步)
* 當消息被真正發送到對等體的時候,IoHandler.messageSent(IoSession,Object)會被調用。
* @param msg 發送的消息
*/
public void write(MyPack msg) {
if (null != session) {
session.write(msg).isWritten();
}
}
/**
* 會話是否已經連接
*/
public boolean isConnected() {
if (null != session) {
return session.isConnected();
}
return false;
}
/**
* 關閉當前連接。如果參數 immediately爲 true的話
* 連接會等到隊列中所有的數據發送請求都完成之後才關閉;否則的話就立即關閉。
*/
public void close(boolean immediately) {
if (null != session) {
if (immediately) {
session.closeNow();
} else {
session.closeOnFlush();
}
}
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (null == obj) {
return false;
}
if (this.getClass() != obj.getClass()) {
return false;
}
// 強轉爲當前類
MySession session = (MySession) obj;
if (session.nid != null && nid != null) {
return session.nid.longValue() == nid.longValue() && session.host.equals(host) && session.port == port;
}
return false;
}
public String toString() {
return "session host:" + this.host + " port:" + this.port + " nid:" + this.nid;
}
// getter/setter...
}
5.2 Session管理接口及其實現類
public interface SessionManager {
/**
* 添加session
*/
void addSession(String device, MySession session);
/**
* 獲取session
*/
MySession getSession(String device);
/**
* 替換Session
*/
void replaceSession(String device, MySession session);
/**
* 刪除session
*/
void removeSession(String device);
/**
* 刪除session
*/
void removeSession(MySession session);
}
實現類:
@Configuration
public class DefaultSessionManagerImpl extends Observable implements SessionManager {
/**
* 存放session的線程安全的map集合
*/
private static ConcurrentHashMap<String, MySession> sessions = new ConcurrentHashMap<>();
/**
* 線程安全的自增類,用於統計連接數
*/
private static final AtomicInteger connectionsCounter = new AtomicInteger(0);
/**
* 添加session
*/
@Override
public void addSession(String account, MySession session) {
if (null != session) {
sessions.put(account, session);
connectionsCounter.incrementAndGet();
// 被觀察者方法,拉模型
setChanged();
notifyObservers();
}
}
/**
* 獲取session
*/
@Override
public MySession getSession(String account) {
return sessions.get(account);
}
/**
* 替換session,通過賬號
*/
@Override
public void replaceSession(String account, MySession session) {
sessions.put(account, session);
// 被觀察者方法,拉模型
setChanged();
notifyObservers();
}
/**
* 移除session通過賬號
*/
@Override
public void removeSession(String account) {
sessions.remove(account);
connectionsCounter.decrementAndGet();
// 被觀察者方法,拉模型
setChanged();
notifyObservers();
}
/**
* 移除session通過session
*/
@Override
public void removeSession(MySession session) {
String account = (String) session.getAttribute(Const.SESSION_KEY);
removeSession(account);
}
public static ConcurrentHashMap<String, MySession> getSessions() {
return sessions;
}
}
六、 業務處理類
6.1 BaseHandler接口
業務處理類都需要實現該接口,該接口定義了一個處理業務邏輯的方法:
/**
* Mina的請求處理接口,必須實現此接口
*
*/
public interface BaseHandler {
String process(MySession mySession, String content);
}
6.2 業務處理核心控制類
NioSocketAcceptor
設置handler後,會調用messageReceived
方法處理業務邏輯。
當讀寫空閒時間超過定義的時間後,會調用該handler的sessionIdle
方法。
public class ServerHandler extends IoHandlerAdapter {
static final Logger logger = LoggerFactory.getLogger(ServerHandler.class);
private HashMap<Integer, BaseHandler> handlers = new HashMap<>();
@Autowired
SessionManager sessionManager;
@Override
public void messageReceived(IoSession session, Object message) throws Exception {
MySession MySession = new MySession(session);
MyPack MyPack = (MyPack) message;
logger.info(MySession.toString() + ">>>>>> server received:" + message);
MyPack response;
// 如果是心跳包接口,則說明處理失敗
if (Const.HEART_BEAT == MyPack.getModule()) {
logger.info(MySession.toString() + ">>>>>> server handler heartbeat error!");
response = new MyPack(Const.AUTHEN, MyPack.getSeq(), "authen fail");
MySession.write(response);
MySession.close(false);
return;
}
// 終端在未認證時連接進來,SERVER端要發送認證失敗的包給終端,然後再斷開連接,防止未知設備連到服務器
if (null == MySession.getAttribute(Const.SESSION_KEY) && Const.AUTHEN != MyPack.getModule()) {
logger.info(MySession.toString() + ">>>>>> need device authen!");
response = new MyPack(Const.AUTHEN, MyPack.getSeq(), "authen fail");
MySession.write(response);
MySession.close(false);
return;
}
BaseHandler handler = handlers.get(MyPack.getModule());
String result = handler.process(MySession, MyPack.getBody());
if (result == null) {
logger.info(MySession.toString() + ">>>>>> need authen!");
response = new MyPack(Const.AUTHEN, MyPack.getSeq(), "deal error");
MySession.write(response);
MySession.close(false);
} else {
logger.info(MySession.toString() + ">>>>>> succeed!");
response = new MyPack(MyPack.getModule(), MyPack.getSeq(), result);
MySession.write(response);
}
}
/**
* 心跳包超時處理
*/
@Override
public void sessionIdle(IoSession session, IdleStatus status) throws Exception {
if (session.getAttribute(Const.TIME_OUT_KEY) == null) {
session.closeNow();
logger.error(
session.getAttribute(Const.SESSION_KEY) + " nid: " + session.getId() + " >>>>>> time_out_key null");
return;
}
try {
int isTimeoutNum = (int) session.getAttribute(Const.TIME_OUT_KEY);
isTimeoutNum++;
// 沒有超過最大次數,超時次數加1
if (isTimeoutNum < Const.TIME_OUT_NUM) {
session.setAttribute(Const.TIME_OUT_KEY, isTimeoutNum);
} else {
// 超過最大次數,關閉會話連接
String account = (String) session.getAttribute(Const.SESSION_KEY);
// 移除device屬性
session.removeAttribute(Const.SESSION_KEY);
// 移除超時屬性
session.removeAttribute(Const.TIME_OUT_KEY);
sessionManager.removeSession(account);
session.closeOnFlush();
logger.info(">>>>>> client user: " + account + " more than " + Const.TIME_OUT_NUM
+ " times have no response, connection closed! >>>>>>");
}
} catch (Exception e) {
logger.error(
session.getAttribute(Const.SESSION_KEY) + " nid: " + session.getId() + " >>>>>> " + e.getMessage());
session.closeNow();
}
}
@Override
public void sessionClosed(IoSession session) throws Exception {
logger.info(session.getAttribute(Const.SESSION_KEY) + " nid: " + session.getId() + " >>>>>> sessionClosed ");
// 移除account屬性
session.removeAttribute(Const.SESSION_KEY);
// 移除超時屬性
session.removeAttribute(Const.TIME_OUT_KEY);
String account = (String) session.getAttribute(Const.SESSION_KEY);
sessionManager.removeSession(account);
session.closeNow();
}
@Override
public void sessionCreated(IoSession session) throws Exception {
InetSocketAddress isa = (InetSocketAddress) session.getRemoteAddress();
// IP
String address = isa.getAddress().getHostAddress();
session.setAttribute("address", address);
logger.info(">>>>>> 來自" + address + " 的終端上線,sessionId:" + session.getId());
}
@Override
public void sessionOpened(IoSession session) throws Exception {
logger.info("Open a session ...");
}
@Override
public void exceptionCaught(IoSession session, Throwable cause) throws Exception {
logger.error(
">>>>>> 終端用戶:" + session.getAttribute(Const.SESSION_KEY) + "連接發生異常,即將關閉連接,原因:" + cause.getMessage());
}
@Override
public void messageSent(IoSession session, Object message) throws Exception {
logger.info(">>>>>>>>>>>>>>>>>>>> 發送消息成功 >>>>>>>>>>>>>>>>>>>>");
}
public HashMap<Integer, BaseHandler> getHandlers() {
return handlers;
}
public void setHandlers(HashMap<Integer, BaseHandler> handlers) {
this.handlers = handlers;
logger.info(">>>>>> server handlers set success!");
}
}
綁定賬號handler:
public class BindHandler implements BaseHandler {
static final Logger logger = LoggerFactory.getLogger(BindHandler.class);
// 獲取會話管理類
@Autowired
private SessionManager sessionManager;
@Override
public String process(MySession mySession, String content) {
if (StringUtils.isBlank(content)) {
return null;
}
try {
JSONObject data = JSONObject.parseObject(content);
// 檢查賬號是否存在
String account = data.getString("account");
// 可增加數據庫、redis之類的認證
if (StringUtils.isBlank(account)) {
return null;
}
// 檢查軟件版本號
String version = data.getString("version");
// 可增加數據庫、redis之類的認證
if (!Const.VERSION.equals(version)) {
return null;
}
mySession.setAttribute(Const.SESSION_KEY, account);
mySession.setAttribute(Const.TIME_OUT_KEY, 0); // 超時次數設爲0
// 由於客戶端斷線服務端可能會無法獲知的情況,客戶端重連時,需要關閉舊的連接
MySession oldSession = sessionManager.getSession(account);
if (oldSession != null && !oldSession.equals(mySession)) {
// 移除account屬性
oldSession.removeAttribute(Const.SESSION_KEY);
// 移除超時時間
oldSession.removeAttribute(Const.TIME_OUT_KEY);
// 替換oldSession
sessionManager.replaceSession(account, mySession);
oldSession.close(false);
logger.info(">>>>>> oldsession close!");
}
if (oldSession == null) {
sessionManager.addSession(account, mySession);
}
logger.info(">>>>>> bind success: " + mySession.getNid());
} catch (Exception e) {
logger.error(">>>>>> bind error: " + e.getMessage());
return null;
}
return "bind success";
}
}
驗證服務器時間handler:
public class TimeCheckHandler implements BaseHandler {
static final Logger logger = LoggerFactory.getLogger(TimeCheckHandler.class);
@Autowired
@Override
public String process(MySession mySession, String content) {
if (StringUtils.isBlank(content)) {
return null;
}
try {
// 平臺系統時間
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
String time = sdf.format(new Date());
return time;
} catch (Exception e) {
logger.error(">>>>>> time check error: " + e.getMessage());
return null;
}
}
}
七、 創建mina連接
在Spring Boot中給我們提供了兩個接口來幫助我們實現這樣的需求-CommandLineRunner和ApplicationRunner
@Component
public class MinaServerRun implements CommandLineRunner{
private static final Logger logger = LoggerFactory.getLogger(MinaServerRun.class);
@Autowired
private NioSocketAcceptor acceptor;
public MinaServerRun(NioSocketAcceptor acceptor) {
this.acceptor = acceptor;
}
@Override
public void run(String... args) throws Exception {
acceptor.bind(new InetSocketAddress(Const.PORT));
logger.info("---springboot mina server start---");
}
}
八、 常量類
public class Const {
public static final int PORT = 8090;
// idel時間,單位秒
public static final int IDELTIMEOUT = 180;
// session_key採用設備編號
public static final String SESSION_KEY = "account";
// 超時KEY
public static final String TIME_OUT_KEY = "time_out";
// 超時次數
public static final int TIME_OUT_NUM = 3;
// 登錄驗證
public static final int AUTHEN = 1;
// 驗證服務器時間
public static final int TIME_CHECK = 2;
// 心跳包
public static final int HEART_BEAT = 3;
// 版本
public static final String VERSION = "V1.0";
}
九、 項目啓動及測試
項目啓動後,控制檯信息如下:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.1.6.RELEASE)
2019-07-06 17:05:32.543 INFO 15572 --- [ main] org.my.SpringbootMinaApplication : Starting SpringbootMinaApplication on PC-20161221CSLG with PID 15572 (D:\workspace\springboot_mina\target\classes started by Administrator in D:\workspace\springboot_mina)
2019-07-06 17:05:32.551 INFO 15572 --- [ main] org.my.SpringbootMinaApplication : No active profile set, falling back to default profiles: default
2019-07-06 17:05:34.474 INFO 15572 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2019-07-06 17:05:34.518 INFO 15572 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2019-07-06 17:05:34.518 INFO 15572 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.21]
2019-07-06 17:05:34.711 INFO 15572 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2019-07-06 17:05:34.711 INFO 15572 --- [ main] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 2078 ms
2019-07-06 17:05:34.849 INFO 15572 --- [ main] org.my.mina.handler.ServerHandler : >>>>>> server handlers set success!
2019-07-06 17:05:35.284 INFO 15572 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
2019-07-06 17:05:35.628 INFO 15572 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2019-07-06 17:05:35.639 INFO 15572 --- [ main] org.my.SpringbootMinaApplication : Started SpringbootMinaApplication in 3.729 seconds (JVM running for 4.901)
2019-07-06 17:05:35.649 INFO 15572 --- [ main] org.my.mina.MinaServerRun : ---springboot mina server start---
測試稍後奉上,敬請期待~