目录 |
---|
IM即时通讯系统[SpringBoot+Netty]——梳理(一) |
IM即时通讯系统[SpringBoot+Netty]——梳理(二) |
IM即时通讯系统[SpringBoot+Netty]——梳理(四) |
IM即时通讯系统[SpringBoot+Netty]——梳理(五) |
七、打通业务服务器与IM服务器多端同步
这里考虑到SDK如何获取到tcp服务的地址
- 可以在SDK上写死一个ip,让这个SDK一直去连接这个地址。如果这个地址挂了,这个服务就会挂掉,这个sdk就得进行修改
- 也可以在SDK上写多个ip,如果这四个ip都挂了,才算挂了
- 暴露一个http请求,每次用户登录的时候,都会往这个请求,拿一次tcp的连接地址,如果是web端获取到web的地址,如果是tcp就拿到tcp的地址
这里我们要使用上面的第三种方式来实现,所以要在逻辑层导入zk方面的东西
@Component
public class ZKit {
private static Logger logger = LoggerFactory.getLogger(ZKit.class);
@Autowired
private ZkClient zkClient;
/**
* get all TCP server node from zookeeper
*
* @return
*/
public List<String> getAllTcpNode() {
List<String> children = zkClient.getChildren(Constants.ImCoreZkRoot + Constants.ImCoreZkRootTcp);
// logger.info("Query all node =[{}] success.", JSON.toJSONString(children));
return children;
}
/**
* get all WEB server node from zookeeper
*
* @return
*/
public List<String> getAllWebNode() {
List<String> children = zkClient.getChildren(Constants.ImCoreZkRoot + Constants.ImCoreZkRootWeb);
// logger.info("Query all node =[{}] success.", JSON.toJSONString(children));
return children;
}
}
通过配置文件动态注入zk的地址和超时间,然后就可以使用上面那个获取到tcp和web的服务地址了
1、负载均衡策略—随机模式
public interface RouteHandle {
public String routeServer(List<String> values, String key);
}
public class RandomHandle implements RouteHandle {
@Override
public String routeServer(List<String> values, String key) {
int size = values.size();
if(size == 0){
throw new ApplicationException(UserErrorCode.SERVER_NOT_AVAILABLE);
}
// 随机获取一个im地址值
int i = ThreadLocalRandom.current().nextInt(size);
return values.get(i);
}
}
将上面的随机算法注入到里面
当我们访问这个接口的时候,就可以随机的获取到地址和端口号
2、负载均衡策略—轮询模式
public class LoopHandle implements RouteHandle {
private AtomicLong index = new AtomicLong();
@Override
public String routeServer(List<String> values, String key) {
int size = values.size();
if(size == 0){
throw new ApplicationException(UserErrorCode.SERVER_NOT_AVAILABLE);
}
Long l = index.incrementAndGet() % size;
if(l < 0){
l = 0L;
}
return values.get(l.intValue());
}
}
这里使用的是一个原子类,每次放温暖的时候都去加1,然后再取模,达到轮询的目的去取地址
将轮询策略注入,测试的时候,每点击一次,就能看到轮询的结果
3、负载均衡策略—一致性Hash
实现一致性hash还需要实现不同的HashMap,这里使用了抽象类,来保证扩展性
public class ConsistentHashHandle implements RouteHandle {
private AbstractConsistentHash abstractConsistentHash;
public void setAbstractConsistentHash(AbstractConsistentHash abstractConsistentHash) {
this.abstractConsistentHash = abstractConsistentHash;
}
// TreeMap实现一致性hash
@Override
public String routeServer(List<String> values, String key) {
return abstractConsistentHash.process(values, key);
}
}
public abstract class AbstractConsistentHash {
// add
protected abstract void add(long key, String value);
// sort
protected void sort(){};
// 获取节点 get
protected abstract String getFirstNodeValue(String value);
// 处理之前事件
protected abstract void processBefore();
// 传入节点列表以及客户端信息获取一个服务节点
public synchronized String process(List<String> values, String key){
processBefore();
for (String value : values) {
add(hash(value), value);
}
sort();
return getFirstNodeValue(key) ;
}
// hash运算
public Long hash(String value){
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
md5.reset();
byte[] keyBytes = null;
try {
keyBytes = value.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Unknown string :" + value, e);
}
md5.update(keyBytes);
byte[] digest = md5.digest();
// hash code, Truncate to 32-bits
long hashCode = ((long) (digest[3] & 0xFF) << 24)
| ((long) (digest[2] & 0xFF) << 16)
| ((long) (digest[1] & 0xFF) << 8)
| (digest[0] & 0xFF);
long truncateHashCode = hashCode & 0xffffffffL;
return truncateHashCode;
}
}
public class TreeMapConsistentHash extends AbstractConsistentHash{
// map
private TreeMap<Long, String> treeMap = new TreeMap<>();
//
private static final int NODE_SIZE = 2;
@Override
protected void add(long key, String value) {
for (int i = 0; i < NODE_SIZE; i++) {
treeMap.put(super.hash("node" + key + i), value);
}
treeMap.put(key, value);
}
@Override
protected String getFirstNodeValue(String value) {
Long hash = super.hash(value);
SortedMap<Long, String> last = treeMap.tailMap(hash);
if(!last.isEmpty()){
return last.get(last.firstKey());
}
if(treeMap.size() == 0){
throw new ApplicationException(UserErrorCode.SERVER_NOT_AVAILABLE);
}
return treeMap.firstEntry().getValue();
}
@Override
protected void processBefore() {
treeMap.clear();
}
}
这里实现的是TreeMap,也可以使用其他的Map去实现,这里一些方法要被重写,一些不用,这个TreeMap要重写add、get方法
这里还要考虑一个问题,比如我们计算出来的hash值是一个10和一个1000000,两个数相差过大,当我们根据userId去获取服务地址的时候,通过调用tailMap可能总是会选择到10或100000,造成一种不均衡的状态,所以这里引入了一种添加虚拟节点的方式,每一个普通节点附加两个虚拟节点,虚拟节点的key是普通节点的key加一些东西,然后value和普通节点的一样,也就是最终比如之前就有10、1000000,但是添加了之后有了10、100、1000、10000、100060、100000,就可以在一定程度上解决老是获取到那固定的服务地址,避免造成涝的涝死,旱死的旱死的局面
测试的时候就可以通过不同的userId去尝试获取不同的服务地址了
4、配置负载均衡策略
之前我们使用的策略都是手动在BeabConfig中修改的,这样的肯定不使用,所以我们要把这个放到配置文件中去,下面的就是去做一些优化
@Configuration
public class BeanConfig {
@Autowired
private AppConfig appConfig;
@Bean
public RouteHandle routeHandle() throws Exception {
// 获取配置文件中使用的哪个路由策略
Integer imRouteWay = appConfig.getImRouteWay();
// 使用的路由策略的具体的路径
String routWay = "";
// 通过配置文件中的路由策略的代表值去Enum获取到具体路径的类
ImUrlRouteWayEnum handler = ImUrlRouteWayEnum.getHandler(imRouteWay);
// 赋值给具体路径
routWay = handler.getClazz();
// 通过反射拿到路由策略的类
RouteHandle routeHandle = (RouteHandle) Class.forName(routWay).newInstance();
// 如果是hash策略的话,还要搞一个具体的hash算法
if (handler == ImUrlRouteWayEnum.HASH){
// 通过反射拿到ConsistentHashHandle中的方法
Method method = Class.forName(routWay).getMethod("setAbstractConsistentHash", AbstractConsistentHash.class);
// 从配置文件中拿到指定hash算法的代表值
Integer consistentHashWay = appConfig.getConsistentHashWay();
// 具体hash算法的类的路径
String hashWay = "";
// 通过Enue拿到对象
RouteHashMethodEnum handler1 = RouteHashMethodEnum.getHandler(consistentHashWay);
// 赋值
hashWay = handler1.getClazz();
// 通过反射拿到hash算法
AbstractConsistentHash abstractConsistentHash = (AbstractConsistentHash) Class.forName(hashWay).newInstance();
method.invoke(routeHandle, abstractConsistentHash);
}
return routeHandle;
}
}
配置
5、使用Apache—HttpClient封装http请求工具
@Configuration
@ConfigurationProperties(prefix = "httpclient")
public class GlobalHttpClientConfig {
private Integer maxTotal; // 最大连接数
private Integer defaultMaxPerRoute; // 最大并发链接数
private Integer connectTimeout; // 创建链接的最大时间
private Integer connectionRequestTimeout; // 链接获取超时时间
private Integer socketTimeout; // 数据传输最长时间
private boolean staleConnectionCheckEnabled; // 提交时检查链接是否可用
PoolingHttpClientConnectionManager manager = null;
HttpClientBuilder httpClientBuilder = null;
// 定义httpClient链接池
@Bean(name = "httpClientConnectionManager")
public PoolingHttpClientConnectionManager getPoolingHttpClientConnectionManager() {
return getManager();
}
private PoolingHttpClientConnectionManager getManager() {
if (manager != null) {
return manager;
}
manager = new PoolingHttpClientConnectionManager();
manager.setMaxTotal(maxTotal); // 设定最大链接数
manager.setDefaultMaxPerRoute(defaultMaxPerRoute); // 设定并发链接数
return manager;
}
/**
* 实例化连接池,设置连接池管理器。 这里需要以参数形式注入上面实例化的连接池管理器
*
* @Qualifier 指定bean标签进行注入
*/
@Bean(name = "httpClientBuilder")
public HttpClientBuilder getHttpClientBuilder(
@Qualifier("httpClientConnectionManager") PoolingHttpClientConnectionManager httpClientConnectionManager) {
// HttpClientBuilder中的构造方法被protected修饰,所以这里不能直接使用new来实例化一个HttpClientBuilder,可以使用HttpClientBuilder提供的静态方法create()来获取HttpClientBuilder对象
httpClientBuilder = HttpClientBuilder.create();
httpClientBuilder.setConnectionManager(httpClientConnectionManager);
return httpClientBuilder;
}
/**
* 注入连接池,用于获取httpClient
*
* @param httpClientBuilder
* @return
*/
@Bean
public CloseableHttpClient getCloseableHttpClient(
@Qualifier("httpClientBuilder") HttpClientBuilder httpClientBuilder) {
return httpClientBuilder.build();
}
public CloseableHttpClient getCloseableHttpClient() {
if (httpClientBuilder != null) {
return httpClientBuilder.build();
}
httpClientBuilder = HttpClientBuilder.create();
httpClientBuilder.setConnectionManager(getManager());
return httpClientBuilder.build();
}
/**
* Builder是RequestConfig的一个内部类 通过RequestConfig的custom方法来获取到一个Builder对象
* 设置builder的连接信息
*
* @return
*/
@Bean(name = "builder")
public RequestConfig.Builder getBuilder() {
RequestConfig.Builder builder = RequestConfig.custom();
return builder.setConnectTimeout(connectTimeout).setConnectionRequestTimeout(connectionRequestTimeout)
.setSocketTimeout(socketTimeout).setStaleConnectionCheckEnabled(staleConnectionCheckEnabled);
}
/**
* 使用builder构建一个RequestConfig对象
*
* @param builder
* @return
*/
@Bean
public RequestConfig getRequestConfig(@Qualifier("builder") RequestConfig.Builder builder) {
return builder.build();
}
public Integer getMaxTotal() {
return maxTotal;
}
public void setMaxTotal(Integer maxTotal) {
this.maxTotal = maxTotal;
}
public Integer getDefaultMaxPerRoute() {
return defaultMaxPerRoute;
}
public void setDefaultMaxPerRoute(Integer defaultMaxPerRoute) {
this.defaultMaxPerRoute = defaultMaxPerRoute;
}
public Integer getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Integer connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Integer getConnectionRequestTimeout() {
return connectionRequestTimeout;
}
public void setConnectionRequestTimeout(Integer connectionRequestTimeout) {
this.connectionRequestTimeout = connectionRequestTimeout;
}
public Integer getSocketTimeout() {
return socketTimeout;
}
public void setSocketTimeout(Integer socketTimeout) {
this.socketTimeout = socketTimeout;
}
public boolean isStaleConnectionCheckEnabled() {
return staleConnectionCheckEnabled;
}
public void setStaleConnectionCheckEnabled(boolean staleConnectionCheckEnabled) {
this.staleConnectionCheckEnabled = staleConnectionCheckEnabled;
}
}
这样就可以根据这个类上面的注解获取到httpclient中的配置文件的属性了
@Component
public class HttpRequestUtils {
@Autowired
private CloseableHttpClient httpClient;
@Autowired
private RequestConfig requestConfig;
@Autowired
GlobalHttpClientConfig httpClientConfig;
public String doGet(String url, Map<String, Object> params, String charset) throws Exception {
return doGet(url,params,null,charset);
}
/**
* 通过给的url地址,获取服务器数据
*
* @param url 服务器地址
* @param params 封装用户参数
* @param charset 设定字符编码
* @return
*/
public String doGet(String url, Map<String, Object> params, Map<String, Object> header, String charset) throws Exception {
if (StringUtils.isEmpty(charset)) {
charset = "utf-8";
}
URIBuilder uriBuilder = new URIBuilder(url);
// 判断是否有参数
if (params != null) {
// 遍历map,拼接请求参数
for (Map.Entry<String, Object> entry : params.entrySet()) {
uriBuilder.setParameter(entry.getKey(), entry.getValue().toString());
}
}
// 声明 http get 请求
HttpGet httpGet = new HttpGet(uriBuilder.build());
httpGet.setConfig(requestConfig);
if (header != null) {
// 遍历map,拼接header参数
for (Map.Entry<String, Object> entry : header.entrySet()) {
httpGet.addHeader(entry.getKey(),entry.getValue().toString());
}
}
String result = "";
try {
// 发起请求
CloseableHttpResponse response = httpClient.execute(httpGet);
// 判断状态码是否为200
if (response.getStatusLine().getStatusCode() == 200) {
// 返回响应体的内容
result = EntityUtils.toString(response.getEntity(), charset);
}
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
return result;
}
/**
* GET请求, 含URL 参数
*
* @param url
* @param params
* @return 如果状态码为200,则返回body,如果不为200,则返回null
* @throws Exception
*/
public String doGet(String url, Map<String, Object> params) throws Exception {
return doGet(url, params, null);
}
/**
* GET 请求,不含URL参数
*
* @param url
* @return
* @throws Exception
*/
public String doGet(String url) throws Exception {
return doGet(url, null, null);
}
public String doPost(String url, Map<String, Object> params, String jsonBody, String charset) throws Exception {
return doPost(url,params,null,jsonBody,charset);
}
/**
* 带参数的post请求
*
* @param url
* @return
* @throws Exception
*/
public String doPost(String url, Map<String, Object> params, Map<String, Object> header, String jsonBody, String charset) throws Exception {
if (StringUtils.isEmpty(charset)) {
charset = "utf-8";
}
URIBuilder uriBuilder = new URIBuilder(url);
// 判断是否有参数
if (params != null) {
// 遍历map,拼接请求参数
for (Map.Entry<String, Object> entry : params.entrySet()) {
uriBuilder.setParameter(entry.getKey(), entry.getValue().toString());
}
}
// 声明httpPost请求
HttpPost httpPost = new HttpPost(uriBuilder.build());
// 加入配置信息
httpPost.setConfig(requestConfig);
// 判断map是否为空,不为空则进行遍历,封装from表单对象
if (StringUtils.isNotEmpty(jsonBody)) {
StringEntity s = new StringEntity(jsonBody, charset);
s.setContentEncoding(charset);
s.setContentType("application/json");
// 把json body放到post里
httpPost.setEntity(s);
}
if (header != null) {
// 遍历map,拼接header参数
for (Map.Entry<String, Object> entry : header.entrySet()) {
httpPost.addHeader(entry.getKey(),entry.getValue().toString());
}
}
String result = "";
// CloseableHttpClient httpClient = HttpClients.createDefault(); // 单个
CloseableHttpResponse response = null;
try {
// 发起请求
response = httpClient.execute(httpPost);
// 判断状态码是否为200
if (response.getStatusLine().getStatusCode() == 200) {
// 返回响应体的内容
result = EntityUtils.toString(response.getEntity(), charset);
}
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
return result;
}
/**
* 不带参数post请求
* @param url
* @return
* @throws Exception
*/
public String doPost(String url) throws Exception {
return doPost(url, null,null,null);
}
/**
* get 方法调用的通用方式
* @param url
* @param tClass
* @param map
* @param charSet
* @return
* @throws Exception
*/
public <T> T doGet(String url, Class<T> tClass, Map<String, Object> map, String charSet) throws Exception {
String result = doGet(url, map, charSet);
if (StringUtils.isNotEmpty(result))
return JSON.parseObject(result, tClass);
return null;
}
/**
* get 方法调用的通用方式
* @param url
* @param tClass
* @param map
* @param charSet
* @return
* @throws Exception
*/
public <T> T doGet(String url, Class<T> tClass, Map<String, Object> map, Map<String, Object> header, String charSet) throws Exception {
String result = doGet(url, map, header, charSet);
if (StringUtils.isNotEmpty(result))
return JSON.parseObject(result, tClass);
return null;
}
/**
* post 方法调用的通用方式
* @param url
* @param tClass
* @param map
* @param jsonBody
* @param charSet
* @return
* @throws Exception
*/
public <T> T doPost(String url, Class<T> tClass, Map<String, Object> map, String jsonBody, String charSet) throws Exception {
String result = doPost(url, map,jsonBody,charSet);
if (StringUtils.isNotEmpty(result))
return JSON.parseObject(result, tClass);
return null;
}
public <T> T doPost(String url, Class<T> tClass, Map<String, Object> map, Map<String, Object> header, String jsonBody, String charSet) throws Exception {
String result = doPost(url, map, header,jsonBody,charSet);
if (StringUtils.isNotEmpty(result))
return JSON.parseObject(result, tClass);
return null;
}
/**
* post 方法调用的通用方式
* @param url
* @param map
* @param jsonBody
* @param charSet
* @return
* @throws Exception
*/
public String doPostString(String url, Map<String, Object> map, String jsonBody, String charSet) throws Exception {
return doPost(url, map,jsonBody,charSet);
}
/**
* post 方法调用的通用方式
* @param url
* @param map
* @param jsonBody
* @param charSet
* @return
* @throws Exception
*/
public String doPostString(String url, Map<String, Object> map, Map<String, Object> header, String jsonBody, String charSet) throws Exception {
return doPost(url, map, header, jsonBody,charSet);
}
}
然后用封装的http请求工具封装一个回调的类
@Component
public class CallbackService {
private Logger logger = LoggerFactory.getLogger(CallbackService.class);
@Autowired
private HttpRequestUtils httpRequestUtils;
@Autowired
private AppConfig appConfig;
@Autowired
private ShareThreadPool shareThreadPool;
public void callback(Integer appId, String callbackCommand, String jsonBody){
shareThreadPool.submit(()->{
try {
httpRequestUtils.doPost(appConfig.getCallbackUrl(), Object.class, builderUrlParams(appId, callbackCommand),
jsonBody, null);
} catch (Exception e) {
logger.error("callback 回调{} : {}出现异常 : {} ",callbackCommand , appId, e.getMessage());
}
});
}
public ResponseVO beforecallback(Integer appId, String callbackCommand, String jsonBody){
try {
ResponseVO responseVO
= httpRequestUtils.doPost("", ResponseVO.class, builderUrlParams(appId, callbackCommand)
, jsonBody, null);
return responseVO;
} catch (Exception e) {
logger.error("callback 之前 回调{} : {}出现异常 : {} ",callbackCommand , appId, e.getMessage());
return ResponseVO.successResponse();
}
}
public Map builderUrlParams(Integer appId, String command){
Map map = new HashMap();
map.put("appId", appId);
map.put("command", command);
return map;
}
}
这个回调的地址也是可以配置在配置文件中的,当你需要得知服务端的状态的时候就可以设置这个回调地址,添加了回调逻辑的操作,就会给你发送一条消息,以便你知晓请求的怎么样了
6、用户资料变更、群组模块回调
这些回调函数的开关都放在配置文件中了,当我们这个修改用户信息成功了以后,调用这个回调函数,往设置好回调地址的地方用http发一条信息,修改群组模块也如此。
7、数据多端同步
技术选取
- 轮询拉取:两台设备每隔一段时间就去拉取,这样做性能有很大的浪费,尤其是移动端,非电费流量
- 业务回调:之前实现了这个,让业务服务器知道谁加谁为好友,这时候调用一个专门用来发消息的接口,告诉A的所有客户端,我加了一次好友,然后去拉取一次好友列表
缺点:一是与Im服务端增加了交互,并且数据的同步强依赖于业务服务器,如果回调的不同,两面的数据还是不同步的;二是如果是客户端通过sdk去拉取好友列表的话,那是一次全量拉取,改变一个好友,就重新拉取所有的列表,又点浪费了
- TCP通知:到收到好友请求后,并且处理成功后,就主动的去发送特定的指令给这个用户的其他端,并且将新添加的好友信息也附带过去,这时候收到这条特色树消息的用户,不需要请求服务器,只是根据这条消息进行更本地好友列表即可,这样既解决了空轮训,也解决了和业务系统强依赖的问题
8、封装查询用户Session工具类
因为要给多端进行同步,所以就要获取到userSession列表,才能给他们去做同步,所以要在service加入redis。
@Component
public class UserSessionUtils {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 1、获取用户所有的session
public List<UserSession> getUserSession(Integer appId, String userId){
// 获取session的key
String userSessionKey = appId + Constants.RedisConstants.UserSessionConstants + userId;
// 获取到这个map
Map<Object, Object> entries =
stringRedisTemplate.opsForHash().entries(userSessionKey);
List<UserSession> list = new ArrayList<>();
Collection<Object> values = entries.values();
for (Object value : values) {
String str = (String)value;
UserSession userSession
= JSONObject.parseObject(str, UserSession.class);
// 只获取在线的
if(userSession.getConnectState() == ImConnectStatusEnum.ONLINE_STATUS.getCode()){
list.add(userSession);
}
}
return list;
}
// 获取指定端的session
public UserSession getUserSession(Integer appId, String userId, Integer clientType, String imei){
// 获取session的key
String userSessionKey = appId + Constants.RedisConstants.UserSessionConstants + userId;
String hashKey = clientType + ":" + imei;
Object o = stringRedisTemplate.opsForHash().get(userSessionKey, hashKey);
UserSession userSession
= JSONObject.parseObject(o.toString(), UserSession.class);
return userSession;
}
}
9、封装MessageProducer给用户发送消息
上一个是封装的获取Session的,这个封装的是给提取出的userSession发送消息的工具类,逻辑层不能给客户端发消息,所以要通过rabbitmq将要发送的消息扔到tcp层,然后再发送给客户端,完成一次发送消息的逻辑
@Service
public class MessageProducer {
private static Logger logger = LoggerFactory.getLogger(MessageProducer.class);
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private UserSessionUtils userSessionUtils;
private String queueName = Constants.RabbitConstants.MessageService2Im;
// 将要发送的信息弄到rabbitmq中去
public boolean sendMessage(UserSession userSession, Object message){
try {
logger.info("send message == " + message);
rabbitTemplate.convertAndSend(queueName, userSession.getBrokerId() + "", message);
return true;
}catch (Exception e){
logger.error("send error :" + e.getMessage());
return false;
}
}
// 发送数据报包,包装数据,调用sendMessage
public boolean sendPack(String toId, Command command, Object msg, UserSession userSession){
MessagePack messagePack = new MessagePack();
messagePack.setCommand(command.getCommand());
messagePack.setToId(toId);
messagePack.setClientType(userSession.getClientType());
messagePack.setAppId(userSession.getAppId());
messagePack.setImei(userSession.getImei());
JSONObject jsonObject = JSONObject.parseObject(JSONObject.toJSONString(msg));
messagePack.setData(jsonObject);
String body = JSONObject.toJSONString(messagePack);
return sendMessage(userSession, body);
}
// 发送给某个用户所有端
public List<ClientInfo> sendToUser(String toId, Command command, Object msg, Integer appId){
List<UserSession> userSession
= userSessionUtils.getUserSession(appId, toId);
List<ClientInfo> list = new ArrayList<>();
for (UserSession session : userSession) {
boolean b = sendPack(toId, command, msg, session);
if(b){
list.add(new ClientInfo(session.getAppId()
, session.getClientType(), session.getImei()));
}
}
return list;
}
// 发送给除了某一端的其他端(这个相当于是对下面那个方法做了一个再封装)
public void sendToUser(String toId, Integer clientType, String imei,
Command command, Object msg, Integer appId){
// 如果imei好和clientType不为空的话,说明就是正常的用户,那就把这个信息发送给除了这个端的其他用户
if(clientType != null && StringUtils.isNotBlank(imei)){
ClientInfo clientInfo = new ClientInfo(appId, clientType, imei);
sendToUserExceptClient(toId, command, msg, clientInfo);
}else{
sendToUser(toId, command, msg, appId);
}
}
// 发送给除了某一端的其他端
public void sendToUserExceptClient(String toId, Command command, Object msg, ClientInfo clientInfo){
List<UserSession> userSession
= userSessionUtils.getUserSession(clientInfo.getAppId(), toId);
for (UserSession session : userSession) {
if(!isMatch(session, clientInfo)){
sendPack(toId, command, msg, session);
}
}
}
// 发送给某个用户的指定客户端
public void sendToUser(String toId, Command command, Object data, ClientInfo clientInfo){
UserSession userSession = userSessionUtils.getUserSession(clientInfo.getAppId(),
toId, clientInfo.getClientType(), clientInfo.getImei());
sendPack(toId, command, data, userSession);
}
private boolean isMatch(UserSession sessionDto, ClientInfo clientInfo) {
return Objects.equals(sessionDto.getAppId(), clientInfo.getAppId())
&& Objects.equals(sessionDto.getImei(), clientInfo.getImei())
&& Objects.equals(sessionDto.getClientType(), clientInfo.getClientType());
}
}
10、编写用户资料、好友模块变更通知
大致意思就是这样,要加的模块按照功能的需求加就完事了
11、封装GroupMessageProducer给用户发送消息
这个和普通的消息还不太一样,因为群组的特殊性,有的操作只用通知群主管理员和被操作人,有的需要都告诉
@Component
public class GroupMessageProducer {
@Autowired
private MessageProducer messageProducer;
@Autowired
private ImGroupMemberService imGroupMemberService;
public void producer(String userId, Command command, Object data, ClientInfo clientInfo){
JSONObject o = (JSONObject) JSONObject.toJSON(data);
String groupId = o.getString("groupId");
// 获取群内的所有群成员的id
List<String> groupMemberId
= imGroupMemberService.getGroupMemberId(groupId, clientInfo.getAppId());
// 加人的时候的TCP通知,只用告诉管理员和本人即可
if(command.equals(GroupEventCommand.ADDED_MEMBER)){
// 发送给管理员和被加入人本身
List<GroupMemberDto> groupManager
= imGroupMemberService.getGroupManager(groupId, clientInfo.getAppId());
AddGroupMemberPack addGroupMemberPack = o.toJavaObject(AddGroupMemberPack.class);
List<String> members = addGroupMemberPack.getMembers();
// 发送给管理员
for (GroupMemberDto groupMemberDto : groupManager) {
if(clientInfo.getClientType() != ClientType.WEBAPI.getCode()
&& groupMemberDto.getMemberId().equals(userId)){
messageProducer.sendToUserExceptClient(groupMemberDto.getMemberId(), command, data, clientInfo);
}else{
messageProducer.sendToUser(groupMemberDto.getMemberId(), command, data, clientInfo.getAppId());
}
}
// 发送给本人的其他端
for (String member : members) {
if(clientInfo.getClientType() != ClientType.WEBAPI.getCode() && member.equals(userId)){
messageProducer.sendToUserExceptClient(member, command, data, clientInfo);
}else{
messageProducer.sendToUser(member, command, data, clientInfo.getAppId());
}
}
}
// 踢人出群的时候的tcp通知
else if(command.equals(GroupEventCommand.DELETED_MEMBER)){
// 获取
RemoveGroupMemberPack pack = o.toJavaObject(RemoveGroupMemberPack.class);
// 删除哪个成员id
String member = pack.getMember();
// 走到这步骤的时候,这个已经被删除了,所以这里查出所有的成员id没有,哪个删除的人
List<String> members
= imGroupMemberService.getGroupMemberId(groupId, clientInfo.getAppId());
// 这里加一下
members.add(member);
// 然后全部通知一下
for (String memberId : members) {
if(clientInfo.getClientType() != ClientType.WEBAPI.getCode() && member.equals(userId)){
messageProducer.sendToUserExceptClient(memberId,command,data,clientInfo);
}else{
messageProducer.sendToUser(memberId,command,data,clientInfo.getAppId());
}
}
}
// 修改成员信息的时候的tcp通知,通知所有管理员
else if(command.equals(GroupEventCommand.UPDATED_MEMBER)){
UpdateGroupMemberPack pack = o.toJavaObject(UpdateGroupMemberPack.class);
// 被修改人的id
String memberId = pack.getGroupId();
// 获取到所有的管理员
List<GroupMemberDto> groupManager
= imGroupMemberService.getGroupManager(groupId, clientInfo.getAppId());
// 将被修改人也要通知到,所以搞一个dto
GroupMemberDto groupMemberDto = new GroupMemberDto();
groupMemberDto.setMemberId(memberId);
groupManager.add(groupMemberDto);
// 全发一遍
for (GroupMemberDto member : groupManager) {
if(clientInfo.getClientType() != ClientType.WEBAPI.getCode() && member.equals(userId)){
messageProducer.sendToUserExceptClient(member.getMemberId(),command,data,clientInfo);
}else{
messageProducer.sendToUser(member.getMemberId(),command,data,clientInfo.getAppId());
}
}
}else{
for (String memberId : groupMemberId) {
// 如果clientType不为空,并且类型不是Web,那么一定就是app端发送的
if(clientInfo.getClientType() != null
&& clientInfo.getClientType() != ClientType.WEBAPI.getCode() && memberId.equals(userId)){
// 发送给除了本端的其他端
messageProducer.sendToUserExceptClient(memberId, command, data, clientInfo);
}else{
// 全发
messageProducer.sendToUser(memberId, command, data, clientInfo.getAppId());
}
}
}
}
}
12、编写群组模块TCP通知
其他就省略了!
13、TCP服务处理逻辑层投递的MQ消息
从mq中获取到消息,然后处理消息,这里用到了工厂模式
public class ProcessFactory {
private static BaseProcess defaultProcess;
static {
defaultProcess = new BaseProcess() {
@Override
public void processBefore() {
}
@Override
public void processAfter() {
}
};
}
public static BaseProcess getMessageProcess(Integer command) {
return defaultProcess;
}
}
public abstract class BaseProcess {
public abstract void processBefore();
public void process(MessagePack messagePack){
processBefore();
// 通过从rabbitmq中拿到的数据报,找到我们要发送给哪个客户端的channel
NioSocketChannel channel = SessionScoketHolder.get(messagePack.getAppId(), messagePack.getToId()
, messagePack.getClientType(), messagePack.getImei());
if(channel != null){
// 如果不为空的话
channel.writeAndFlush(messagePack);
}
processAfter();
}
public abstract void processAfter();
}
14、接口调用鉴权加密—加解密算法HMAC-SHA256
因为调用接口的人,可能是app的用户,也有可能是后台管理员,所以要选择一个可逆的加密
加密
public class Base64URL {
public static byte[] base64EncodeUrl(byte[] input) {
byte[] base64 = new BASE64Encoder().encode(input).getBytes();
for (int i = 0; i < base64.length; ++i)
switch (base64[i]) {
case '+':
base64[i] = '*';
break;
case '/':
base64[i] = '-';
break;
case '=':
base64[i] = '_';
break;
default:
break;
}
return base64;
}
public static byte[] base64EncodeUrlNotReplace(byte[] input) {
byte[] base64 = new BASE64Encoder().encode(input).getBytes(Charset.forName("UTF-8"));
for (int i = 0; i < base64.length; ++i)
switch (base64[i]) {
case '+':
base64[i] = '*';
break;
case '/':
base64[i] = '-';
break;
case '=':
base64[i] = '_';
break;
default:
break;
}
return base64;
}
public static byte[] base64DecodeUrlNotReplace(byte[] input) throws IOException {
for (int i = 0; i < input.length; ++i)
switch (input[i]) {
case '*':
input[i] = '+';
break;
case '-':
input[i] = '/';
break;
case '_':
input[i] = '=';
break;
default:
break;
}
return new BASE64Decoder().decodeBuffer(new String(input,"UTF-8"));
}
public static byte[] base64DecodeUrl(byte[] input) throws IOException {
byte[] base64 = input.clone();
for (int i = 0; i < base64.length; ++i)
switch (base64[i]) {
case '*':
base64[i] = '+';
break;
case '-':
base64[i] = '/';
break;
case '_':
base64[i] = '=';
break;
default:
break;
}
return new BASE64Decoder().decodeBuffer(base64.toString());
}
}
public class SigAPI {
final private long appId;
final private String key;
public SigAPI(long appId, String key) {
this.appId = appId;
this.key = key;
}
public static void main(String[] args) throws InterruptedException {
SigAPI asd = new SigAPI(10000, "123456");
String sign = asd.genUserSig("lld", 1000000);
// Thread.sleep(2000L);
JSONObject jsonObject = decodeUserSig(sign);
System.out.println("sign:" + sign);
System.out.println("decoder:" + jsonObject.toString());
}
/**
* @description: 解密方法
* @param
* @return com.alibaba.fastjson.JSONObject
* @author lld
*/
public static JSONObject decodeUserSig(String userSig) {
JSONObject sigDoc = new JSONObject(true);
try {
byte[] decodeUrlByte = Base64URL.base64DecodeUrlNotReplace(userSig.getBytes());
byte[] decompressByte = decompress(decodeUrlByte);
String decodeText = new String(decompressByte, "UTF-8");
if (StringUtils.isNotBlank(decodeText)) {
sigDoc = JSONObject.parseObject(decodeText);
}
} catch (Exception ex) {
ex.printStackTrace();
}
return sigDoc;
}
/**
* 解压缩
*
* @param data 待压缩的数据
* @return byte[] 解压缩后的数据
*/
public static byte[] decompress(byte[] data) {
byte[] output = new byte[0];
Inflater decompresser = new Inflater();
decompresser.reset();
decompresser.setInput(data);
ByteArrayOutputStream o = new ByteArrayOutputStream(data.length);
try {
byte[] buf = new byte[1024];
while (!decompresser.finished()) {
int i = decompresser.inflate(buf);
o.write(buf, 0, i);
}
output = o.toByteArray();
} catch (Exception e) {
output = data;
e.printStackTrace();
} finally {
try {
o.close();
} catch (IOException e) {
e.printStackTrace();
}
}
decompresser.end();
return output;
}
/**
* 【功能说明】用于签发 IM 服务中必须要使用的 UserSig 鉴权票据
* <p>
* 【参数说明】
*/
public String genUserSig(String userid, long expire) {
return genUserSig(userid, expire, null);
}
private String hmacsha256(String identifier, long currTime, long expire, String base64Userbuf) {
String contentToBeSigned = "TLS.identifier:" + identifier + "\n"
+ "TLS.appId:" + appId + "\n"
+ "TLS.expireTime:" + currTime + "\n"
+ "TLS.expire:" + expire + "\n";
if (null != base64Userbuf) {
contentToBeSigned += "TLS.userbuf:" + base64Userbuf + "\n";
}
try {
byte[] byteKey = key.getBytes(StandardCharsets.UTF_8);
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(byteKey, "HmacSHA256");
hmac.init(keySpec);
byte[] byteSig = hmac.doFinal(contentToBeSigned.getBytes(StandardCharsets.UTF_8));
return (Base64.getEncoder().encodeToString(byteSig)).replaceAll("\\s*", "");
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
return "";
}
}
private String genUserSig(String userid, long expire, byte[] userbuf) {
long currTime = System.currentTimeMillis() / 1000;
JSONObject sigDoc = new JSONObject();
sigDoc.put("TLS.identifier", userid);
sigDoc.put("TLS.appId", appId);
sigDoc.put("TLS.expire", expire);
sigDoc.put("TLS.expireTime", currTime);
String base64UserBuf = null;
if (null != userbuf) {
base64UserBuf = Base64.getEncoder().encodeToString(userbuf).replaceAll("\\s*", "");
sigDoc.put("TLS.userbuf", base64UserBuf);
}
String sig = hmacsha256(userid, currTime, expire, base64UserBuf);
if (sig.length() == 0) {
return "";
}
sigDoc.put("TLS.sig", sig);
Deflater compressor = new Deflater();
compressor.setInput(sigDoc.toString().getBytes(StandardCharsets.UTF_8));
compressor.finish();
byte[] compressedBytes = new byte[2048];
int compressedBytesLength = compressor.deflate(compressedBytes);
compressor.end();
return (new String(Base64URL.base64EncodeUrl(Arrays.copyOfRange(compressedBytes,
0, compressedBytesLength)))).replaceAll("\\s*", "");
}
public String genUserSig(String userid, long expire, long time,byte [] userbuf) {
JSONObject sigDoc = new JSONObject();
sigDoc.put("TLS.identifier", userid);
sigDoc.put("TLS.appId", appId);
sigDoc.put("TLS.expire", expire);
sigDoc.put("TLS.expireTime", time);
String base64UserBuf = null;
if (null != userbuf) {
base64UserBuf = Base64.getEncoder().encodeToString(userbuf).replaceAll("\\s*", "");
sigDoc.put("TLS.userbuf", base64UserBuf);
}
String sig = hmacsha256(userid, time, expire, base64UserBuf);
if (sig.length() == 0) {
return "";
}
sigDoc.put("TLS.sig", sig);
Deflater compressor = new Deflater();
compressor.setInput(sigDoc.toString().getBytes(StandardCharsets.UTF_8));
compressor.finish();
byte[] compressedBytes = new byte[2048];
int compressedBytesLength = compressor.deflate(compressedBytes);
compressor.end();
return (new String(Base64URL.base64EncodeUrl(Arrays.copyOfRange(compressedBytes,
0, compressedBytesLength)))).replaceAll("\\s*", "");
}
}
Handler
@Component
public class GateWayInterceptor implements HandlerInterceptor {
@Autowired
private IdentityCheck identityCheck;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 方便测试
if(1 == 1){
return true;
}
// 获取appid 操作人 usersign
String appIdStr = request.getParameter("appId");
if(StringUtils.isBlank(appIdStr)){
resp(ResponseVO.errorResponse(GateWayErrorCode.APPID_NOT_EXIST), response);
return false;
}
String identifier = request.getParameter("identifier");
if(StringUtils.isBlank(identifier)){
resp(ResponseVO.errorResponse(GateWayErrorCode.OPERATER_NOT_EXIST), response);
return false;
}
String userSign = request.getParameter("userSign");
if(StringUtils.isBlank(userSign)){
resp(ResponseVO.errorResponse(GateWayErrorCode.USERSIGN_IS_ERROR), response);
return false;
}
// 校验签名和操作人和appId是否匹配
ApplicationExceptionEnum applicationExceptionEnum
= identityCheck.checkUserSign(identifier, appIdStr, userSign);
if(applicationExceptionEnum != BaseErrorCode.SUCCESS){
resp(ResponseVO.errorResponse(applicationExceptionEnum), response);
return false;
}
return true;
}
private void resp(ResponseVO responseVO, HttpServletResponse response){
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try {
String resp = JSONObject.toJSONString(responseVO);
writer = response.getWriter();
writer.write(resp);
}catch (Exception e){
e.printStackTrace();
}finally {
if(writer != null){
writer.close();
}
}
}
}
IdentityCheck
@Component
public class IdentityCheck {
private static Logger logger = LoggerFactory.getLogger(IdentityCheck.class);
@Autowired
private ImUserService imUserService;
@Autowired
private AppConfig appConfig;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public ApplicationExceptionEnum checkUserSign(String identifier, String appId, String userSign){
String cacheUserSig
= stringRedisTemplate.opsForValue().get(appId + ":"
+ Constants.RedisConstants.userSign + ":" + identifier + userSign);
if(!StringUtils.isBlank(cacheUserSig) && Long.valueOf(cacheUserSig) > System.currentTimeMillis() / 1000){
return BaseErrorCode.SUCCESS;
}
// 获取秘钥
String privatekey = appConfig.getPrivatekey();
// 根据appId + 秘钥 创建 signApi
SigAPI sigAPI = new SigAPI(Long.valueOf(appId), privatekey);
// 调用 signApi 对 userSign解密
JSONObject jsonObject = sigAPI.decodeUserSig(userSign);
// 取出解密后的appId, 和操作人 和过期时间 做匹配,不通过则提示错误
Long expireTime = 0L;
Long expireSec = 0L;
Long time = 0L;
String decoderAppId = "";
String decoderIdentifier = "";
try {
// 取出解密后的数据
decoderAppId = jsonObject.getString("TLS.appId");
decoderIdentifier = jsonObject.getString("TLS.identifier");
String expireStr = jsonObject.get("TLS.expire").toString();
String expireTimeStr = jsonObject.get("TLS.expireTime").toString();
time = Long.valueOf(expireTimeStr);
expireSec = Long.valueOf(expireStr);
expireTime = time + expireSec;
}catch (Exception e){
logger.error("checkUserSig-error: {}", e.getMessage());
e.printStackTrace();
}
// 进行比对
// 用户签名和操作人不匹配
if(!decoderIdentifier.equals(identifier)){
return GateWayErrorCode.USERSIGN_OPERATE_NOT_MATE;
}
// 用户签名不正确
if(!decoderAppId.equals(appId)){
return GateWayErrorCode.USERSIGN_IS_ERROR;
}
// 过期时间
if(expireSec == 0){
return GateWayErrorCode.USERSIGN_IS_EXPIRED;
}
if(expireTime < System.currentTimeMillis() / 1000){
return GateWayErrorCode.USERSIGN_IS_EXPIRED;
}
// 把userSign存储到redis中去
// appid + "xxx" + "userId" + sign
String genSig = sigAPI.genUserSig(identifier, expireSec,time,null);
if (genSig.toLowerCase().equals(userSign.toLowerCase())) {
String key = appId + ":" + Constants.RedisConstants.userSign + ":" + identifier + userSign;
Long etime = expireTime - System.currentTimeMillis() / 1000;
stringRedisTemplate.opsForValue().set(
key, expireTime.toString(), etime, TimeUnit.SECONDS);
this.setIsAdmin(identifier,Integer.valueOf(appId));
return BaseErrorCode.SUCCESS;
}
return BaseErrorCode.SUCCESS;
}
private void setIsAdmin(String identifier, Integer appId) {
//去DB或Redis中查找, 后面写
ResponseVO<ImUserDataEntity> singleUserInfo = imUserService.getSingleUserInfo(identifier, appId);
if(singleUserInfo.isOk()){
RequestHolder.set(singleUserInfo.getData().getUserType() == ImUserTypeEnum.APP_ADMIN.getCode());
}else{
RequestHolder.set(false);
}
}
}
把Handler加到spring中
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private GateWayInterceptor gateWayInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(gateWayInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/v1/user/login")
.excludePathPatterns("/v1/message/checkSend");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");
}
}
这样我们在访问接口的时候,就要带上appId、identifier、userSign,就可以了
八、消息业务的流程之打通消息收发核心
1、消息收发的核心流程
在我们发消息的时候,一条消息前面就会出现一个转圈圈的东西,也就是此时将这个消息发送到了tcp层,然后tcp层再去投递到逻辑层,最终实现消息处理的还是逻辑层,当逻辑层处理完成之后,再投递到tcp层,最终tcp层将消息返回给sdk,app收到这条消息后,将那个圈圈去掉
还有其他的情况,消息前有感叹号,也就是逻辑层判断了这条消息不能被发送,然后就要通过tcp层去告知sdk他不能被发送,sdk告诉app这条消息不能发送,就搞一个红色的感叹号
2、单聊消息分发逻辑—RabbitMQ连接tcp层和网关层
先搞清楚,业务回调是因为连接客户端的是Tcp层,而service层不会直接连接到客户端,所以要通过一个http的业务回调机制,可以让客户端和service层进行感知,而数据多端同步是通过向tcp层的队列中投递消息,然后再由tcp层去分发到其他的客户端上,做数据同步,而这里只是单方面的service层连接到tcp层,service层没有接收到tcp层的rabbitmq的消息,所以这里要打通这个关系
下面的就是service层接受tcp层投递给service层的消息
@Component
public class ChatOperateReceiver {
private static Logger logger = LoggerFactory.getLogger(ChatOperateReceiver.class);
@Autowired
private P2PMessageService p2PMessageService;
@Autowired
private MessageSyncService messageSyncService;
// 这个注解就是消费者获取rabbitmq中的信息
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(value = Constants.RabbitConstants.Im2MessageService, durable = "true"),
exchange = @Exchange(value = Constants.RabbitConstants.Im2MessageService, durable = "true")
),concurrency = "1"
)
public void onChatMessage(@Payload Message message, @Headers Map<String, Object> headers,
Channel channel) throws IOException {
String msg = new String(message.getBody(), "utf-8");
logger.info("CHAT MSG FROM QUEUE ::: {}", msg);
long deliveryTag = (long) headers.get(AmqpHeaders.DELIVERY_TAG);
try {
JSONObject jsonObject = JSONObject.parseObject(msg);
Integer command = jsonObject.getInteger("command");
if(command.equals(MessageCommand.MSG_P2P.getCommand())){
// 处理消息
MessageContent messageContent
= jsonObject.toJavaObject(MessageContent.class);
p2PMessageService.process(messageContent);
}else if(command.equals(MessageCommand.MSG_RECIVE_ACK.getCommand())){
// 消息接受确认
MessageReciveAckContent messageContent
= jsonObject.toJavaObject(MessageReciveAckContent.class);
messageSyncService.receiveMark(messageContent);
}else if(command.equals(MessageCommand.MSG_READED.getCommand())){
MessageReadedContent messageReadedContent
= jsonObject.toJavaObject(MessageReadedContent.class);
messageSyncService.readMark(messageReadedContent);
}else if(Objects.equals(command, MessageCommand.MSG_RECALL.getCommand())){
RecallMessageContent messageContent = JSON.parseObject(msg, new TypeReference<RecallMessageContent>() {
}.getType());
messageSyncService.recallMessage(messageContent);
}
channel.basicAck(deliveryTag, false);
}catch (Exception e){
logger.error("处理消息出现异常:{}", e.getMessage());
logger.error("RMQ_CHAT_TRAN_ERROR", e);
logger.error("NACK_MSG:{}", msg);
//第一个false 表示不批量拒绝,第二个false表示不重回队列
channel.basicNack(deliveryTag, false, false);
}
}
}
通过解析出消息中的comman命令,然后做进一步的处理
然后在tcp层向service层投递,这样当客户端发送过来消息的时候,就会投递到service层中去
3、单聊消息分发逻辑—发送消息前置校验
前置校验要校验的是双方是否被禁用或者禁言,双方是否是好友关系
@Service
public class CheckSendMessageService {
@Autowired
private ImUserService imUserService;
@Autowired
private ImFriendShipService imFriendShipService;
@Autowired
private AppConfig appConfig;
@Autowired
private ImGroupService imGroupService;
@Autowired
private ImGroupMemberService imGroupMemberService;
// 判断发送发是否被禁用或者禁言
public ResponseVO checkSenderForvidAndMute(String fromId, Integer appId){
// 获取单个用户
ResponseVO<ImUserDataEntity> singleUserInfo
= imUserService.getSingleUserInfo(fromId, appId);
if(!singleUserInfo.isOk()){
return singleUserInfo;
}
// 取出用户
ImUserDataEntity user = singleUserInfo.getData();
// 是否被禁用
if(user.getForbiddenFlag() == UserForbiddenFlagEnum.FORBIBBEN.getCode()){
return ResponseVO.errorResponse(MessageErrorCode.FROMER_IS_FORBIBBEN);
}
// 是否被禁言
else if(user.getSilentFlag() == UserSilentFlagEnum.MUTE.getCode()){
return ResponseVO.errorResponse(MessageErrorCode.FROMER_IS_MUTE);
}
return ResponseVO.successResponse();
}
// 判断好友关系
public ResponseVO checkFriendShip(String fromId, String toId, Integer appId){
if(appConfig.isSendMessageCheckFriend()){
// 判断双方好友记录是否存在
GetRelationReq fromReq = new GetRelationReq();
fromReq.setFromId(fromId);
fromReq.setToId(toId);
fromReq.setAppId(appId);
ResponseVO<ImFriendShipEntity> fromRelation = imFriendShipService.getRelation(fromReq);
if(!fromRelation.isOk()){
return fromRelation;
}
GetRelationReq toReq = new GetRelationReq();
toReq.setFromId(toId);
toReq.setToId(fromId);
toReq.setAppId(appId);
ResponseVO<ImFriendShipEntity> toRelation = imFriendShipService.getRelation(toReq);
if(!toRelation.isOk()){
return toRelation;
}
// 判断好友关系记录是否正常
if(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode() != fromRelation.getData().getStatus()){
return ResponseVO.errorResponse(FriendShipErrorCode.FRIEND_IS_DELETED);
}
if(FriendShipStatusEnum.FRIEND_STATUS_NORMAL.getCode() != toRelation.getData().getStatus()){
return ResponseVO.errorResponse(FriendShipErrorCode.FRIEND_IS_DELETED);
}
// 判断是否在黑名单里面
if(appConfig.isSendMessageCheckBlack()){
if(FriendShipStatusEnum.BLACK_STATUS_NORMAL.getCode()
!= fromRelation.getData().getBlack()){
return ResponseVO.errorResponse(FriendShipErrorCode.FRIEND_IS_BLACK);
}
if(FriendShipStatusEnum.BLACK_STATUS_NORMAL.getCode()
!= toRelation.getData().getBlack()){
return ResponseVO.errorResponse(FriendShipErrorCode.TARGET_IS_BLACK_YOU);
}
}
}
return ResponseVO.successResponse();
}
}
然后就可以去调用前置校验在真正处理消息的之前了
4、单聊消息分发逻辑—消息分发的主流程
- 前置校验
- 回复ack
- 同步我方在线对端
- 分发给对方所有端
5、详细分析群聊业务
6、发送群聊消息前置校验
和上面单聊的差不多
// 前置校验群组消息
public ResponseVO checkGroupMessage(String fromId, String groupId, Integer appId){
// 发送方是否被禁言
ResponseVO responseVO = checkSenderForvidAndMute(fromId, appId);
if(!responseVO.isOk()){
return responseVO;
}
// 判断群逻辑
ResponseVO<ImGroupEntity> group = imGroupService.getGroup(groupId, appId);
if(!group.isOk()){
return group;
}
// 判断群成员是否在群内
ResponseVO<GetRoleInGroupResp> roleInGroupOne
= imGroupMemberService.getRoleInGroupOne(groupId, fromId, appId);
if(!roleInGroupOne.isOk()){
return roleInGroupOne;
}
GetRoleInGroupResp data = roleInGroupOne.getData();
// 判断群是否被禁言
//如果禁言,只有群管理和群主可以发言
ImGroupEntity groupdata = group.getData();
// 如果群组已经禁言并且 发言人不是群管理或者群主
if(groupdata.getMute() == GroupMuteTypeEnum.MUTE.getCode()
&& (data.getRole() != GroupMemberRoleEnum.MAMAGER.getCode() ||
data.getRole() != GroupMemberRoleEnum.OWNER.getCode())){
return ResponseVO.errorResponse(GroupErrorCode.THIS_GROUP_IS_MUTE);
}
// 如果是个人禁言,并且还在禁言时长中
if(data.getSpeakDate() != null && data.getSpeakDate() > System.currentTimeMillis()){
return ResponseVO.errorResponse(GroupErrorCode.GROUP_MEMBER_IS_SPEAK);
}
return ResponseVO.successResponse();
}
7、群聊消息的分发逻辑
- 前置校验
- 回复ack
- 同步我方在线对端
- 分发给对方所有端
8、聊天记录存储结构单聊群聊读扩散、写扩散选型
读扩散
举一个微博大V的例子,如果大V发一条消息,那么关注了大V的用户,就会从大V的队列中倒序拉取就可以获取到大V的消息了
写扩散
也举一个微博大V的例子,如果大V发一条消息,每个用户都有自己的一个队列,大V会将消息写到所有订阅他的用户的队列中
从这上面看的话,要是查询聊天记录的话,如果面对好多好多的用户来说的,写扩散要写好多的东西,读扩散反而只需要去读取即可,看起来读扩散比写扩散好一些
基础数据
这样使用读扩散似乎是减轻了写操作的压力,但是增加了读操作的压力,这样子的查询语句根本不好去建立索引
使用写扩散的话,写起来会些麻烦,但是查询聊天记录很快速
如果说聊天记录很多,我们上上升到分库分表的场景,读扩散没有给合适的分片键,没有像写扩散那样的ownerId那样的标识。
存储结构
拆分开来
选型:
- 单聊服务建议使用写扩散,也就是写操作X2,不会一下子给服务器太多的压力,而且比较好查询
- 群聊服务建议使用读扩散,群聊有群id作为分片键,在群id上建立索引,就可以查询出这个群的所有的消息
9、 IM消息ID专题—分布式自增id解决方案介绍
- UUID:id长、无序、字符串
- 时间戳:存在重复性问题,而且不能保证唯一性
- 雪花算法:用一个64位的长整型作为全局唯一id,基于时间戳实现,性能高,但是基于时间戳会依赖于机器时钟,如果时间回拨会导致号段重复
- 自定义算法:像私有协议一样
10、如何将单聊、群聊消息持久化到DB
将私聊消息转换成对应的实体,然后分别存储到body和history中去
// 3、转化成 MessageHistory
public List<ImMessageHistoryEntity> extractToP2PMessageHistory(MessageContent messageContent
, ImMessageBodyEntity imMessageBodyEntity){
List<ImMessageHistoryEntity> list = new ArrayList<>();
ImMessageHistoryEntity fromHistory = new ImMessageHistoryEntity();
BeanUtils.copyProperties(messageContent, fromHistory);
fromHistory.setOwnerId(messageContent.getFromId());
// 雪花算法生成
fromHistory.setMessageKey(imMessageBodyEntity.getMessageKey());
fromHistory.setCreateTime(System.currentTimeMillis());
ImMessageHistoryEntity toHistory = new ImMessageHistoryEntity();
BeanUtils.copyProperties(messageContent, toHistory);
toHistory.setOwnerId(messageContent.getToId());
toHistory.setMessageKey(imMessageBodyEntity.getMessageKey());
toHistory.setCreateTime(System.currentTimeMillis());
list.add(fromHistory);
list.add(toHistory);
return list;
}
public ImMessageBody extractMessageBody(MessageContent messageContent){
ImMessageBody imMessageBodyEntity = new ImMessageBody();
imMessageBodyEntity.setAppId(messageContent.getAppId());
imMessageBodyEntity.setMessageKey(snowflakeIdWorker.nextId());
imMessageBodyEntity.setCreateTime(System.currentTimeMillis());
imMessageBodyEntity.setSecurityKey("");
imMessageBodyEntity.setExtra(messageContent.getExtra());
imMessageBodyEntity.setDelFlag(DelFlagEnum.NORMAL.getCode());
imMessageBodyEntity.setMessageTime(messageContent.getMessageTime());
imMessageBodyEntity.setMessageBody(messageContent.getMessageBody());
return imMessageBodyEntity;
}
// 转化成 MessageHistory
public ImGroupMessageHistoryEntity extractToGroupMessageHistory(GroupChatMessageContent groupChatMessageContent,
ImMessageBodyEntity imMessageBodyEntity){
ImGroupMessageHistoryEntity result
= new ImGroupMessageHistoryEntity();
BeanUtils.copyProperties(groupChatMessageContent, result);
result.setGroupId(groupChatMessageContent.getGroupId());
// 雪花算法生成
result.setMessageKey(imMessageBodyEntity.getMessageKey());
result.setCreateTime(System.currentTimeMillis());
return result;
}
11、实现发送单聊和群聊的接口
// 发送群聊消息
public SendMessageResp send(SendGroupMessageReq req) {
SendMessageResp sendMessageResp = new SendMessageResp();
GroupChatMessageContent message = new GroupChatMessageContent();
BeanUtils.copyProperties(req, message);
// 插入
messageStoreService.storeGroupMessage(message);
sendMessageResp.setMessageKey(message.getMessageKey());
sendMessageResp.setMessageTime(System.currentTimeMillis());
// 我方同步在线端
syncToSender(message, message);
// 对方同步
dispatchMessage(message);
return sendMessageResp;
}
// 发送单聊消息
public SendMessageResp send(SendMessageReq req) {
SendMessageResp sendMessageResp = new SendMessageResp();
MessageContent message = new MessageContent();
BeanUtils.copyProperties(req, message);
// 插入数据
messageStoreService.storeP2PMessage(message);
sendMessageResp.setMessageKey(message.getMessageKey());
sendMessageResp.setMessageTime(System.currentTimeMillis());
// 同步我方在线端
syncToSender(message, message);
// 同步对方在线端
dispatchMessage(message);
return sendMessageResp;
}