首先利用springboot的插件新建一个maven项目
一、 pom.xml 所需依赖
首先加入mina核心依赖
<!-- https://mvnrepository.com/artifact/org.apache.mina/mina-core -->
<dependency>
<groupId>org.apache.mina</groupId>
<artifactId>mina-core</artifactId>
<version>2.1.3</version>
</dependency>
为了进行socket通信,还需要加入另一个依赖:
<!-- https://mvnrepository.com/artifact/org.apache.mina/mina-integration-beans -->
<dependency>
<groupId>org.apache.mina</groupId>
<artifactId>mina-integration-beans</artifactId>
<version>2.1.3</version>
</dependency>
此时pom文件会报错Missing artifact org.apache.mina:mina-core:bundle:2.1.3
需要添加如下插件:
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<extensions>true</extensions>
</plugin>
二、 mina配置类
mina需要哪些配置类呢?一个一个分析。
2.1 设置I/O接收器
设置I/O接收器,指定接收到请求后交给handler处理
此部分被 NioSocketAcceptor 隐式使用,无此则会报字符串无法转换成 InetSocketAddress
从字符串到 SocketAddress 的转换,会偿试使用该自定义属性编辑器
具体解释参照
Mina 配置中的 CustomEditorConfigurer 上代码
/**
* 设置I/O接收器
* @return
*/
private static Map<Class<?>, Class<? extends PropertyEditor>> customEditors = new HashMap<>();
@Bean
public static CustomEditorConfigurer customEditorConfigurer() {
customEditors.put(SocketAddress.class, InetSocketAddressEditor.class);
CustomEditorConfigurer configurer = new CustomEditorConfigurer();
configurer.setCustomEditors(customEditors);
return configurer;
}
如果不加mina-integration-beans
依赖,会找不到InetSocketAddressEditor
这个类
2.2 多线程处理
在处理流程中加入线程池,可以较好的提高服务器的吞吐量,但也带来了新的问题:请求的处理顺序问题。在单线程的模型下,可以保证IO请求是挨个顺序地处理的。加入线程池之后,同一个IoSession的多个IO请求可能被ExecutorFilter并行的处理,这对于一些对请求处理顺序有要求的程序来说是不希望看到的。比如:数据库服务器处理同一个会话里的prepare,execute,commit请求希望是能按顺序逐一执行的。
Mina里默认的实现是有保证同一个IoSession中IO请求的顺序的。具体的实现是,ExecutorFilter默认采用了Mina提供的OrderedThreadPoolExecutor作为内置线程池。后者并不会立即执行加入进来的Runnable对象,而是会先从Runnable对象里获取关联的IoSession(这里有个down cast成IoEvent的操作),并将Runnable对象加入到session的任务列表中。OrderedThreadPoolExecutor会按session里任务列表的顺序来处理请求,从而保证了请求的执行顺序。
对于没有顺序要请求的情况,可以为ExecutorFilter指定一个Executor来替换掉默认的OrderedThreadPoolExecutor,让同一个session的多个请求能被并行地处理,来进一步提高吞吐量。
参考文章:Mina工作原理分析
/**
* 线程池filter
*/
@Bean
public ExecutorFilter executorFilter() {
return new ExecutorFilter();
}
2.3 日志过滤器
/**
* 日志信息注入过滤器,MDC(Mapped Diagnostic Context有译作线程映射表)是日志框架维护的一组信息键值对,可向日志输出信息中插入一些想要显示的内容。
*
*/
@Bean
public MdcInjectionFilter mdcInjectionFilter() {
return new MdcInjectionFilter(MdcInjectionFilter.MdcKey.remoteAddress);
}
/**
* 日志filter
*/
@Bean
public LoggingFilter loggingFilter() {
return new LoggingFilter();
}
2.4 编解码器filter
/**
* 编解码器filter
*/
@Bean
public ProtocolCodecFilter protocolCodecFilter() {
return new ProtocolCodecFilter(new MyProtocolCodecFactory());
}
此时迎来了第一个重点,编解码器。
稍后单独列出这个自定义编解码工厂的实现。
2.5 心跳包
@Bean
public KeepAliveFactoryImpl keepAliveFactoryImpl() {
return new KeepAliveFactoryImpl();
}
/**
* 心跳filter
*/
@Bean
public KeepAliveFilter keepAliveFilter(KeepAliveFactoryImpl keepAliveFactory) {
// 注入心跳工厂,读写空闲
KeepAliveFilter filter = new KeepAliveFilter(keepAliveFactory, IdleStatus.BOTH_IDLE);
// 设置是否forward到下一个filter
filter.setForwardEvent(true);
// 设置心跳频率,单位是秒,我这里设置的180,也就是3分钟
filter.setRequestInterval(Const.IDELTIMEOUT);
return filter;
}
心跳包是第二大重点,稍后单独列出心跳包的实现。
2.6 业务处理类
本例列举两个业务处理类:BindHandler
、TimeCheckHandler
心跳包业务是单独实现的
BindHandler
负责处理登录业务TimeCheckHandler
负责获取服务器的系统时间
private HashMap<Integer, BaseHandler> handlers = new HashMap<>();
@Bean
public ServerHandler serverHandler() {
handlers.put(Const.AUTHEN, new BindHandler());
handlers.put(Const.TIME_CHECK, new TimeCheckHandler());
ServerHandler serverHandler = new ServerHandler();
serverHandler.setHandlers(handlers);
return serverHandler;
}
BaseHandler
接口稍后展示。handlers
集合是为了方便处理业务时,根据不同模块获取相应的处理类,稍后看具体的实现
2.7 过滤器链
主要用于拦截和过滤网络传输中I/O操作的各种消息,是在应用层和我们业务员层之间的过滤层
主要作用:
记录事件的日志(Mina默认提供了LoggingFilter)
测量系统性能
信息验证
过载控制
信息的转换(主要就是编码和解码)
和其他更多的信息
最后将心跳包检测加入到过滤器链中
@Bean
public DefaultIoFilterChainBuilder defaultIoFilterChainBuilder(ExecutorFilter executorFilter,
MdcInjectionFilter mdcInjectionFilter, ProtocolCodecFilter protocolCodecFilter, LoggingFilter loggingFilter,
KeepAliveFilter keepAliveFilter) {
DefaultIoFilterChainBuilder filterChainBuilder = new DefaultIoFilterChainBuilder();
Map<String, IoFilter> filters = new LinkedHashMap<>();
filters.put("mdcInjectionFilter", mdcInjectionFilter);
filters.put("protocolCodecFilter", protocolCodecFilter);
filters.put("executor", executorFilter);
filters.put("keepAliveFilter", keepAliveFilter);
filterChainBuilder.setFilters(filters);
return filterChainBuilder;
}
注意:
当你使用自定的ProtocolCodecFactory时候一定要将线程池配置在该过滤器之后,因为你自己实现的ProtocolCodecFactory直接读取和转换的是二进制数据,这些数据都是由和CPU绑定的I/O Processor来读取和发送的,因此为了不影响系统的性能,也应该将数据的编解码操作绑定到I/O Processor线程中,因为在Java中创建和线程切换都是比较耗资源的,因此建议将ProtocolCodecFactory配置在ExecutorFilter的前面
2.8 创建连接
/**
* 创建连接
* @return
*/
@Bean(initMethod = "init", destroyMethod = "dispose")
public NioSocketAcceptor nioSocketAcceptor(ServerHandler serverHandler,
DefaultIoFilterChainBuilder defaultIoFilterChainBuilder) {
NioSocketAcceptor acceptor = new NioSocketAcceptor();
acceptor.getSessionConfig().setReadBufferSize(2048);
acceptor.getSessionConfig().setIdleTime(IdleStatus.BOTH_IDLE, Const.IDELTIMEOUT);
// 绑定过滤器链
acceptor.setFilterChainBuilder(defaultIoFilterChainBuilder);
acceptor.setHandler(serverHandler);
return acceptor;
}
三、 自定义协议以及编解码器
底层的传输与交互都是采用二进制的方式。
如何判断发送的消息已经结束,就需要通过协议来规定,比如收到换行符等标识时,判断为结束等。
根据协议,把二进制数据转换成Java对象称为解码(也叫做拆包),把Java对象转换为二进制数据称为编码(也叫做打包)。
常用的协议制定方法:
- 定长消息法:这种方式是使用长度固定的数据发送,一般适用于指令发送。譬如:数据发送端规定发送的数据都是双字节,AA 表示启动、BB 表示关闭等等
- 字符定界法:这种方式是使用特殊字符作为数据的结束符,一般适用于简单数据的发送。譬如:在消息的结尾自动加上文本换行符(Windows使用\r\n,Linux使用\n),接收方见到文本换行符就认为是一个完整的消息,结束接收数据开始解析。注意:这个标识结束的特殊字符一定要简单,常常使用ASCII码中的特殊字符来标识(会出现粘包、半包情况)。
- 定长报文头法:使用定长报文头,在报文头的某个域指明报文长度。该方法最灵活,使用最广。譬如:协议为– 协议编号(1字节)+数据长度(4个字节)+真实数据。请求到达后,解析协议编号和数据长度,根据数据长度来判断后面的真实数据是否接收完整。HTTP 协议的消息报头中的Content-Length 也是表示消息正文的长度,这样数据的接收端就知道到底读到多长的字节数就不用再读取数据了。
实际应用中,采用最多的还是定长报文头法。
本例采用的是定长报文头法。
3.1 自定义协议
首先来实现一个自定义协议:
协议组成: 数据长度(4个字节) + 协议编号(1字节)+ 模块代码(4字节)+ 序列号(4字节)+ 真实数据。
实体类如下:
public class MyPack {
// 数据总长度
private int len;
// 数据传输类型:0x00-设备到服务端 0x01-服务端到设备
private byte type = 0x01;
// 模块代码
private int module;
/**
* 此域表示一个序列号,使用在异步通信模式下,由消息发起者设定,应答者对应给回此序列号。
* 序列号范围:0000-9999,循环使用。
* 同步方式下该域保留。
**/
private String seq;
// 包体
private String body;
/**
* 0x00表示客户端到服务端
*/
public static final byte REQUEST = 0x00;
/**
* 0x01表示服务端到客户端
*/
public static final byte RESPONSE = 0x01;
// 包头长度
public static final int PACK_HEAD_LEN = 13;
// 最大长度
public static final int MAX_LEN = 9999;
public MyPack(int module, String seq, String body) {
this.module = module;
this.seq = seq;
this.body = body;
// 总长度
this.len = PACK_HEAD_LEN + (StringUtils.isBlank(body) ? 0 : body.getBytes().length);
}
// getter/setter...
}
3.2 自定义编解码器及工厂类
在添加自定义编码器ProtocolCodecFilter时,需要注入一个ProtocolCodecFactory编解码工厂,查看ProtocolCodecFactory接口,发现需要实现2个方法,该接口的两个方法需要返回ProtocolDecoder和ProtocolEncoder的实现类对象:
public interface ProtocolCodecFactory {
ProtocolEncoder getEncoder(IoSession session) throws Exception;
ProtocolDecoder getDecoder(IoSession session) throws Exception;
}
这就需要我们对编码器、解码器进行实现。
自定义编解码器工厂类的实现如下:
public class MyProtocolCodecFactory implements ProtocolCodecFactory {
private final ProtocolEncoder encoder;
private final ProtocolDecoder decoder;
public MyProtocolCodecFactory() {
this(Charset.forName("UTF-8"));
}
public MyProtocolCodecFactory(Charset charset) {
this.encoder = new MyProtocolEncoder(charset);
this.decoder = new MyProtocolDecoder(charset);
}
@Override
public ProtocolEncoder getEncoder(IoSession session) throws Exception {
return encoder;
}
@Override
public ProtocolDecoder getDecoder(IoSession session) throws Exception {
return decoder;
}
}
3.3 自定义编码器
编码器的作用是将JAVA对象转换成二进制流,然后发送给客户端。
可以通过继承ProtocolEncoderAdapter类或实现ProtocolEncoder接口来实现自定义编码。
本例采用的方式是实现ProtocolEncoder
接口。
public class MyProtocolEncoder implements ProtocolEncoder {
private final Charset charset;
public MyProtocolEncoder() {
this.charset = Charset.defaultCharset();
}
public MyProtocolEncoder(Charset charset) {
this.charset = charset;
}
@Override
public void encode(IoSession session, Object message, ProtocolEncoderOutput out) throws Exception {
MyPack myPack = (MyPack) message;
IoBuffer ioBuffer = IoBuffer.allocate(myPack.getLen()).setAutoExpand(true);
System.out.println("encode length: " + myPack.getLen());
ioBuffer.putInt(myPack.getLen());
ioBuffer.put(myPack.getType());
ioBuffer.putInt(myPack.getModule());
ioBuffer.putString(myPack.getSeq(), charset.newEncoder());
if (StringUtils.isNotBlank(myPack.getBody())) {
System.out.println("encoder body length:" + myPack.getBody().getBytes().length);
ioBuffer.putString(myPack.getBody(), charset.newEncoder());
}
ioBuffer.flip();
out.write(ioBuffer);
}
@Override
public void dispose(IoSession session) throws Exception {
// TODO Auto-generated method stub
}
}
注意,在write前,结束iobuffer操作后,必须将ioBuffer进行flip操作,具体原因查看IoBuffer的使用方法
3.4 自定义解码器
解码器的作用是将二进制流转换成JAVA对象,通过服务端的解析将数据转化成需要的协议包。
可以通过实现ProtocolDecoder接口或继承ProtocolDecoderAdapter类来实现自定义解码,但是难以解决半包、粘包问题。
一般都是采用继承CumulativeProtocolDecoder类,重写doDecode方法,可以解决半包、粘包问题
public class MyProtocolDecoder extends CumulativeProtocolDecoder {
static final Logger logger = LoggerFactory.getLogger(MyProtocolDecoder.class);
private final Charset charset;
public MyProtocolDecoder() {
this.charset = Charset.defaultCharset();
}
public MyProtocolDecoder(Charset charset) {
this.charset = charset;
}
@Override
protected boolean doDecode(IoSession session, IoBuffer in, ProtocolDecoderOutput out) throws Exception {
if (in.remaining() < MyPack.PACK_HEAD_LEN) {
return false;
}
if (in.remaining() > MyPack.MAX_LEN) {
// 数据长度大于最大长度,出现数据错误,丢弃
clearData(in);
return false;
}
else {
in.mark();
try {
// 数据长度4字节
int length = in.getInt();
logger.info("decode length:" + length);
if (length < MyPack.PACK_HEAD_LEN) {
// 数据长度小于包头长度,出现数据错误,丢弃
clearData(in);
return false;
}
int i = in.remaining();
logger.info("remaing:" + i);
if (i < (length - 4)) {
// 内容不够, 重置position到操作前,进行下一轮接受新数据
in.reset();
return false;
} else {
in.reset();
// 数据传输类型
byte type = in.get();
if (type != MyPack.REQUEST) {
// 不是客户端发送的数据,出现数据错误,丢弃
clearData(in);
return false;
}
// 模块id
int module = in.getInt();
// 序列号
String seq_no = in.getString(4, charset.newDecoder());
String body = in.getString(length - MyPack.PACK_HEAD_LEN, charset.newDecoder());
MyPack myPack = new MyPack(module, seq_no, body);
logger.info(">>>>>> server decode result: " + myPack.toString());
out.write(myPack);
return in.remaining() > 0;
}
} catch (Exception e) {
// 解析有异常,抛弃异常数据,不能影响正常通信
logger.error(">>>>>> decode error: " + e.toString());
clearData(in);
return false;
}
}
}
private void clearData(IoBuffer in) {
byte[] bytes = new byte[in.remaining()];
in.get(bytes);
bytes = null;
}
}
doDecode方法说明:
- doDecode()方法返回true 时,CumulativeProtocolDecoder 的decode()方法会首先判断你是否在doDecode()方法中从内部的IoBuffer 缓冲区读取了数据,如果没有,则会抛出非法的状态异常,也就是你的doDecode()方法返回true 就表示你已经消费了本次数据(相当于聊天室中一个完整的消息已经读取完毕),进一步说,也就是此时你必须已经消费过内部的IoBuffer 缓冲区的数据(哪怕是消费了一个字节的数据)。如果验证过通过,那么CumulativeProtocolDecoder 会检查缓冲区内是否还有数据未读取,如果有就继续调用doDecode()方法,没有就停止对doDecode()方法的调用,直到有新的数据被缓冲。
- doDecode()方法返回false 时,CumulativeProtocolDecoder 会停止对doDecode()方法的调用,但此时如果本次数据还有未读取完的,就将含有剩余数据的IoBuffer 缓冲区保存到IoSession 中,以便下一次数据到来时可以从IoSession 中提取合并。如果发现本次数据全都读取完毕,则清空IoBuffer 缓冲区
简而言之,当你认为读取到的数据已经够解码了,那么就返回true,否则就返回false。这个CumulativeProtocolDecoder 其实最重要的工作就是帮你完成了数据的累积,因为这个工作是很烦琐的。也就是说返回true,那么CumulativeProtocolDecoder会再次调用decoder,并把剩余的数据发下来返回false就不处理剩余的,当有新数据包来的时候把剩余的和新的拼接在一起然后再调用decoder。
clearData(IoBuffer in)
方法是为了清空IoBuffer中的数据,防止再次读取