【手撸IM】高性能HTTP API服务设计与实现
- 【手撸IM】专题初衷为实现一个分布式、高性能、支持多租户的 IM 云原型,主要目的为开源学习交流。
- 源码地址:https://gitee.com/bossfriday/bossfriday-nubybear
1. 背景
在互联网应用快速发展的今天,后端服务对 高性能、高并发、低延迟 的需求越来越强烈。传统基于 Servlet 的 HTTP 框架(如 Spring Boot + Tomcat/Jetty)虽然生态丰富,但在性能和灵活性上存在一定的瓶颈。本文将介绍一种基于 Netty 与 ActorRPC 的高性能 Http API 服务架构方案,并结合实际代码实现进行讲解。
2. 方案对比
传统的Spring Boot 方案虽然开发简单,但是效率不高,毕竟需要完整的 Servlet 容器栈,而Servlet 规范决定了很多额外开销,例如:Filter 链、Request/Response 包装、线程池调度等。同时,Spring Boot调用链较长,需要穿过 DispatcherServlet → HandlerMapping → HandlerAdapter → Controller → 序列化层 → Response 包装。
Netty + ActorRPC 则是“定制化高性能”方案,在高并发、低延迟场景下能比 Spring Boot 延迟低、内存开销小,支撑更高连接数。毕竟使用Netty可以直接操作 ChannelHandlerContext,协议解析没有 Servlet 的冗余,同时Netty是一个优秀的NIO框架,基于 事件驱动 + Reactor 模型,通常是 少量 IO 线程 + 任务线程池。而ActorRPC天然异步,内部消息传递直接是对象或轻量级二进制协议,tell 回 sender 时避免了额外的 HTTP 调度(sender 保存了 ChannelHandlerContext ctx,业务逻辑完成后直接 ctx.writeAndFlush() HTTP应答),这样可以做到调用链更短,对象拷贝和方法调用次数更少。
综上,如果是IM、游戏、实时风控、网关等要求高性能的场景下选择后者明显更好,如果是云服务,为了减少服务器成本开销也应当首选后者。当然利弊往往是相生相伴的,使用Netty + ActorRPC方案开发成本高、维护复杂度高,毕竟缺乏缺乏 Spring Boot 的生态支持很多基础工作需要自行实现。
3. 核心代码实现
3.1 流程
3.2 核心代码实现
3.2.1 HttpApiServerHandler
package cn.bossfriday.im.api.http;import cn.bossfriday.common.http.HttpProcessorMapper;
import cn.bossfriday.common.http.IHttpProcessor;
import cn.bossfriday.im.api.helper.ApiHelper;
import cn.bossfriday.im.common.api.ApiResponseHelper;
import cn.bossfriday.im.common.entity.result.ResultCode;
import cn.bossfriday.im.common.enums.api.ApiRequestType;
import cn.bossfriday.im.common.helper.AppHelper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.FullHttpRequest;
import lombok.extern.slf4j.Slf4j;import java.net.URI;
import java.util.Objects;import static cn.bossfriday.im.common.constant.ApiConstant.*;/*** HttpApiServerHandler** @author chenx*/
@Slf4j
public class HttpApiServerHandler extends ChannelInboundHandlerAdapter {@Overridepublic void channelRead(ChannelHandlerContext ctx, Object msg) {FullHttpRequest httpRequest = null;try {if (msg instanceof FullHttpRequest) {httpRequest = (FullHttpRequest) msg;this.onMessageReceived(ctx, httpRequest);}} finally {if (httpRequest != null && httpRequest.refCnt() > 0) {httpRequest.release();}}}@Overridepublic void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {log.error("HttpApiServerHandler.exceptionCaught()", cause);if (ctx.channel().isActive()) {ctx.channel().close();}}/*** onMessageReceived*/private void onMessageReceived(ChannelHandlerContext ctx, FullHttpRequest httpRequest) {try {URI uri = new URI(httpRequest.uri());ApiRequestType requestType = ApiRequestType.find(httpRequest.method().name(), uri);if (Objects.isNull(requestType)) {ApiResponseHelper.sendApiResponse(ctx, ResultCode.API_UNSUPPORTED);return;}ResultCode authResult = ApiHelper.auth(httpRequest);if (authResult.getCode() != ResultCode.OK.getCode()) {ApiResponseHelper.sendApiResponse(ctx, authResult);return;}String apiVersion = requestType.getUrlParser().parsePath(uri).get(HTTP_URL_ARGS_API_VERSION);long appId = AppHelper.getAppId(httpRequest.headers().get(HTTP_HEADER_APP_KEY));ctx.channel().attr(ATTRIBUTE_KEY_API_VERSION).set(apiVersion);ctx.channel().attr(ATTRIBUTE_KEY_APP_ID).set(appId);IHttpProcessor processor = HttpProcessorMapper.getHttpProcessor(requestType.getApiRouteKey());processor.process(ctx, httpRequest);} catch (Exception ex) {log.error("HttpApiServerHandler.onMessageReceived() error!", ex);ApiResponseHelper.sendApiResponse(ctx, ResultCode.SYSTEM_ERROR);}}
}
3.2.2 ApiRequestType
package cn.bossfriday.im.common.enums.api;import cn.bossfriday.common.exception.ServiceRuntimeException;
import cn.bossfriday.common.http.UrlParser;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import io.netty.handler.codec.http.HttpMethod;
import lombok.Getter;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang.StringUtils;import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Objects;import static cn.bossfriday.im.common.constant.ApiConstant.*;/*** ApiRequestType** @author chenx*/
public enum ApiRequestType {/*** client api*/CLIENT_NAV(API_ROUTE_KEY_CLIENT_NAV, HttpMethod.POST.name(), new UrlParser(String.format("/api/{%s}/client/nav", HTTP_URL_ARGS_API_VERSION))),/*** user api*/USER_GET_TOKEN(API_ROUTE_KEY_USER_GET_TOKEN, HttpMethod.POST.name(), new UrlParser(String.format("/api/{%s}/user/getToken", HTTP_URL_ARGS_API_VERSION))),;@Getterprivate String apiRouteKey;@Getterprivate String httpMethod;@Getterprivate UrlParser urlParser;ApiRequestType(String apiRouteKey, String httpMethod, UrlParser urlParser) {this.apiRouteKey = apiRouteKey;this.httpMethod = httpMethod;this.urlParser = urlParser;}private static final Map<String, List<ApiRequestType>> API_REQUEST_TYPE_MAP = Maps.newHashMap();static {for (ApiRequestType entry : ApiRequestType.values()) {List<ApiRequestType> apiRequestTypeList = API_REQUEST_TYPE_MAP.get(entry.httpMethod);if (Objects.isNull(apiRequestTypeList)) {apiRequestTypeList = Lists.newArrayList();API_REQUEST_TYPE_MAP.put(entry.httpMethod, apiRequestTypeList);}apiRequestTypeList.add(entry);}}/*** getByMethod** @param httpMethod* @return*/public static List<ApiRequestType> getByMethod(String httpMethod) {if (StringUtils.isEmpty(httpMethod)) {throw new ServiceRuntimeException("httpMethod is empty!");}return API_REQUEST_TYPE_MAP.get(httpMethod);}/*** find* <p>* find方法实际上是一个遍历查找,如果ApiRequestType.getByMethod(httpMethod)返回的list条目较多时,效率可能不好。* 后续备选优化方案:使用 Trie(前缀树)来提高匹配速度。构建 Trie 结构后,查找 URL 不需要遍历 List,时间复杂度降低为 O(m)(m 为 URL 片段数)。** @param httpMethod* @param uri* @return*/public static ApiRequestType find(String httpMethod, URI uri) {List<ApiRequestType> list = ApiRequestType.getByMethod(httpMethod);if (CollectionUtils.isEmpty(list)) {return null;}for (ApiRequestType entry : list) {if (entry.getUrlParser().isMatch(uri)) {return entry;}}return null;}
}
3.2.3 HttpProcessorMapper
package cn.bossfriday.common.http;import cn.bossfriday.common.exception.ServiceRuntimeException;import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;/*** HttpProcessorMapper** @author chenx*/
public class HttpProcessorMapper {private static HashMap<String, Class<? extends IHttpProcessor>> processorMapper = new HashMap<>();private HttpProcessorMapper() {// do nothing}/*** putHttpProcessor** @param apiRouteKey* @param httpProcessor*/public static Class<? extends IHttpProcessor> putHttpProcessor(String apiRouteKey, Class<? extends IHttpProcessor> httpProcessor) {return processorMapper.putIfAbsent(apiRouteKey, httpProcessor);}/*** getHttpProcessor** @param apiRouteKey* @return*/public static IHttpProcessor getHttpProcessor(String apiRouteKey) throws InstantiationException,IllegalAccessException,NoSuchMethodException,InvocationTargetException {if (!contains(apiRouteKey)) {throw new ServiceRuntimeException("IHttpProcessor not existed! apiRouteKey=" + apiRouteKey);}Class<? extends IHttpProcessor> processor = processorMapper.get(apiRouteKey);return processor.getConstructor().newInstance();}/*** contains** @param apiRouteKey* @return*/public static boolean contains(String apiRouteKey) {return processorMapper.containsKey(apiRouteKey);}
}
3.2.4 GetTokenProcessor(HttpProcessor)
package cn.bossfriday.im.api.processor.user;import cn.bossfriday.common.register.HttpApiRoute;
import cn.bossfriday.common.rpc.actor.ActorRef;
import cn.bossfriday.im.api.actor.ApiAckActor;
import cn.bossfriday.im.common.api.BaseHttpProcessor;
import cn.bossfriday.im.common.message.api.user.GetTokenRequest;
import cn.bossfriday.im.common.message.rpc.user.GetTokenInput;
import cn.bossfriday.im.common.rpc.message.ApiMessage;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.FullHttpRequest;
import lombok.extern.slf4j.Slf4j;import static cn.bossfriday.im.common.constant.ApiConstant.API_ROUTE_KEY_USER_GET_TOKEN;
import static cn.bossfriday.im.common.constant.ImConstant.ACTOR_USER_GET_TOKEN;/*** GetTokenProcessor** @author chenx*/
@Slf4j
@HttpApiRoute(apiRouteKey = API_ROUTE_KEY_USER_GET_TOKEN)
public class GetTokenProcessor extends BaseHttpProcessor {@Overrideprotected void doRequest(ChannelHandlerContext ctx, FullHttpRequest httpRequest, String apiVersion, long appId) {GetTokenRequest request = this.getRequestPayload(httpRequest, GetTokenRequest.class);GetTokenInput input = GetTokenInput.builder().userId(request.getUserId()).userName(request.getUserName()).deviceId(request.getDeviceId()).build();ApiMessage apiMessage = this.getApiMessage(apiVersion,appId,ACTOR_USER_GET_TOKEN,request.getUserId(),request.getUserId(),input);ActorRef sender = this.getSender(ApiAckActor.class, ctx);this.routeMessage(apiMessage, sender);}
}
3.2.5 GetTokenActor(ProcessActor)
package cn.bossfriday.im.user.actors;import cn.bossfriday.common.plugin.PluginSpringContext;
import cn.bossfriday.common.register.ActorRoute;
import cn.bossfriday.common.rpc.actor.ActorRef;
import cn.bossfriday.im.common.codec.ImTokenCodec;
import cn.bossfriday.im.common.db.entity.AppInfo;
import cn.bossfriday.im.common.entity.ImToken;
import cn.bossfriday.im.common.entity.result.Result;
import cn.bossfriday.im.common.helper.AppHelper;
import cn.bossfriday.im.common.message.rpc.user.GetTokenInput;
import cn.bossfriday.im.common.message.rpc.user.GetTokenOutput;
import cn.bossfriday.im.common.rpc.BaseActor;
import cn.bossfriday.im.common.service.UserInfoService;
import lombok.extern.slf4j.Slf4j;import static cn.bossfriday.im.common.constant.ImConstant.ACTOR_USER_GET_TOKEN;
import static cn.bossfriday.im.common.entity.result.ResultCode.SYSTEM_ERROR;/*** GetTokenActor** @author chenx*/
@Slf4j
@ActorRoute(methods = ACTOR_USER_GET_TOKEN)
public class GetTokenActor extends BaseActor<GetTokenInput> {@Overridepublic void onMessageReceived(GetTokenInput msg) {try {long appId = this.getContext().getAppId();String uid = msg.getUserId();String deviceId = msg.getDeviceId();long time = System.currentTimeMillis();// create tokenAppInfo appInfo = AppHelper.getAppInfo(appId);ImToken imToken = new ImToken(appId, appInfo.getAppSecret(), uid, deviceId, time);String token = ImTokenCodec.encode(imToken);// register userUserInfoService userInfoService = PluginSpringContext.getBean(UserInfoService.class);userInfoService.register(appId, uid, msg.getUserName());GetTokenOutput output = GetTokenOutput.builder().token(token).userId(msg.getUserId()).build();this.getSender().tell(Result.ok(output), ActorRef.noSender());} catch (Exception ex) {log.error("GetTokenActor.onMessageReceived() error!", ex);this.getSender().tell(Result.error(SYSTEM_ERROR), ActorRef.noSender());}}
}
3.2.6 ApiAckActor
package cn.bossfriday.im.api.actor;import cn.bossfriday.common.register.ActorRoute;
import cn.bossfriday.common.rpc.actor.BaseUntypedActor;
import cn.bossfriday.im.common.api.ApiResponseHelper;
import cn.bossfriday.im.common.entity.result.Result;
import io.netty.channel.ChannelHandlerContext;
import lombok.extern.slf4j.Slf4j;import static cn.bossfriday.im.common.constant.ImConstant.ACTOR_API_ACK;
import static cn.bossfriday.im.common.entity.result.ResultCode.API_UNSUPPORTED_API_ACK_MESSAGE_TYPE;
import static cn.bossfriday.im.common.entity.result.ResultCode.SYSTEM_ERROR;/*** ApiAckActor:公共API回调Actor** @author chenx*/
@Slf4j
@ActorRoute(methods = ACTOR_API_ACK)
public class ApiAckActor extends BaseUntypedActor {private ChannelHandlerContext ctx;public ApiAckActor(ChannelHandlerContext ctx) {this.ctx = ctx;}@Overridepublic void onMsgReceive(Object msg) {try {if (msg instanceof Result) {ApiResponseHelper.sendApiResponse(this.ctx, (Result<?>) msg);return;}ApiResponseHelper.sendApiResponse(this.ctx, API_UNSUPPORTED_API_ACK_MESSAGE_TYPE);} catch (Exception ex) {log.error("ApiAckActor.onMsgReceive() error!", ex);ApiResponseHelper.sendApiResponse(this.ctx, SYSTEM_ERROR);}}
}
4. 总结
从上面的主要代码可以看出,虽然缺乏 Spring Boot 的生态支持很多基础工作需要自行实现,但是一些基础代码实现之后如果要新加一个接口那么只需要如下3步,想想其实跟Spring Boot也差不多是吧,但是性能上却能获得极大收益。
1、ApiRequestType扩展一个枚举值; --类比SpringBoot中写RequestMapping注解;
2、新增一个BaseHttpProcessor实现; --类比加Controller中的方法;
3、新增一个BaseActor实现; --类比常规的Service实现;
运行效果截图: