一、引言
二、技术选型与前提条件
三、核心代码实现
服务的启动类 ChatServer
初始化器类 WSServerInitializer
心跳Handler类 HeartBeatHandler
OkHttpUtil工具类
json实体转换工具类 JsonUtils
发送消息的类型/动作枚举类 MsgTypeEnum
消息实体类 ChatMsg
自定义通信Handler类 ChatHandler
会话用户id和channel的关联处理类(确保线程安全) UserChannelSession
四、结语
五、其他参考文章
在即时通讯(IM)系统架构中,通信层是核心基础设施,负责实现消息的实时、可靠传输。本文将详细介绍如何使用Netty框架结合WebSocket协议构建一个高性能、轻量级且专注通信功能的独立IM后台服务。该服务将作为整个IM系统的消息中枢,专注于解决以下核心问题:
实时消息传输:支持单聊/群聊的全双工通信
连接管理:用户多端连接配对管理
消息路由:实现点对点精准投递
协议适配:基于WebSocket的标准协议交互
设计边界:
本文实现的Netty通信服务作为独立服务存在,其他服务的可以通过REST/RPC接口或OKHTTP等方式进行调用,但有些功能的实现会用TODO介绍思路,以下IM系统功能不在本文讨论范围:
用户关系管理(好友系统)、多媒体文件存储、社交功能(朋友圈、点赞等)、消息历史存储等等。
这种设计遵循单一职责原则,使通信服务可独立扩展并适配不同业务场景。
Netty:高性能异步事件驱动的网络应用框架
WebSocket:全双工通信协议,适合实时通讯场景
JDK版本:JDK11+ (在Java生态中,JDK 11和JDK 17作为LTS(Long-Term Support,长期支持)版本,相比JDK 8在NIO性能、GC优化和整体架构上有显著提升,这些改进对Netty+WebSocket这类高并发网络应用的性能影响尤为明显,具体可以通过AI询问进行了解)
pom.xml文件
io.netty
netty-all
4.1.25.Final
com.a3test.component
idworker-snowflake
1.0.0
/**
* WebSocket 聊天服务器主启动类
* 基于Netty框架实现的WebSocket服务器,用于处理实时聊天通信
*/
public class ChatServer {
/**
* 主方法 - 启动WebSocket服务器
*/
public static void main(String[] args) throws Exception {
// ==================== 线程组初始化 ====================
// 主线程组(Boss线程组): 负责接收客户端连接
// 类比:公司老板,只负责接单不处理具体业务
EventLoopGroup bossGroup = new NioEventLoopGroup();
// 工作线程组(Worker线程组): 处理已建立连接的数据读写
// 类比:公司员工,负责处理老板接来的具体业务
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
// ==================== 服务器配置 ====================
// 创建服务器启动引导类
ServerBootstrap server = new ServerBootstrap();
// 配置线程模型
server.group(bossGroup, workerGroup)
// 指定通道类型为NIO模式
.channel(NioServerSocketChannel.class)
// 初始化通道处理器链(包含WebSocket协议处理器等)
.childHandler(new WSServerInitializer());
// ==================== 端口绑定 ====================
// 绑定监听端口875(sync()表示同步等待绑定操作完成)
// 实际生产环境建议使用配置文件管理端口号
ChannelFuture channelFuture = server.bind(875).sync();
// WebSocket服务器已启动,监听端口: 875
// 访问地址: ws://127.0.0.1:875/ws
// ==================== 等待关闭 ====================
// 阻塞直到服务器通道关闭(通常是人为关闭或发生异常)
// 这行代码会让主线程持续运行,保持服务可用状态
channelFuture.channel().closeFuture().sync();
} finally {
// ==================== 资源清理 ====================
// 优雅关闭线程池(先平滑停止接收新请求,再处理完已接收请求)
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
/**
* WebSocket 通道初始化器
* 负责配置新建立的SocketChannel的处理器链(Pipeline)
* 注:每个客户端连接都会创建一个新的Channel和对应的Pipeline
*/
public class WSServerInitializer extends ChannelInitializer {
/**
* 初始化通道处理器链
* @param channel 新建立的Socket通道
* @throws Exception 可能抛出的异常
*/
@Override
protected void initChannel(SocketChannel channel) throws Exception {
// 获取通道的处理器管道(数据会依次按照addLast的顺序经过管道中的各个处理器)
ChannelPipeline pipeline = channel.pipeline();
/* ==================== HTTP协议相关处理器 ==================== */
// HTTP编解码器(组合了HttpRequestDecoder和HttpResponseEncoder)
// 作用:将字节流解码为HTTP请求/将HTTP响应编码为字节流
// 注意:WebSocket握手阶段使用HTTP协议,因此需要此处理器
pipeline.addLast(new HttpServerCodec());
// 大数据块写入处理器
// 作用:支持异步写入大型数据流,防止内存溢出
// 典型应用场景:文件传输、大文件下载等
pipeline.addLast(new ChunkedWriteHandler());
// HTTP消息聚合器(最大聚合64KB内容)
// 作用:将多个HTTP消息片段聚合成完整的FullHttpRequest/FullHttpResponse
// 注意:设置过大会增加内存消耗,过小可能导致大请求被拒绝
pipeline.addLast(new HttpObjectAggregator(1024 * 64));
/* ==================== 心跳检测机制 ==================== */
// 空闲状态检测处理器(参数单位:秒)
// 参数说明:读空闲(8s), 写空闲(10s), 全部空闲(300*60s=5小时)
// 工作原理:超过指定时间未对应操作会触发IdleStateEvent事件
pipeline.addLast(new IdleStateHandler(8, 10, 300 * 60));
// 自定义心跳处理器(需自行实现HeartBeatHandler)
// 功能:处理IdleStateEvent事件,可能包括:
// 1. 发送心跳请求(写空闲时)
// 2. 断开不活跃连接(读空闲超时)
pipeline.addLast(new HeartBeatHandler());
/* ==================== WebSocket协议处理器 ==================== */
// WebSocket协议处理器(指定访问路径为/ws)
// 核心功能:
// 1. 处理WebSocket握手(Upgrade握手)
// 2. 处理控制帧(Close/Ping/Pong)
// 3. 维护WebSocket协议状态
// 注意:实际业务消息会以二进制帧或文本帧的形式传递
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
/* *************** 可选增强配置 *************** */
// 1. 可添加SSL处理器保证安全通信:
// pipeline.addFirst("ssl", new SslHandler(sslContext.newEngine(channel.alloc())));
// 2. 可添加 自定义 IP过滤/黑名单处理器
// 3. 可添加 自定义 流量整形处理器控制速率
// 自定义业务处理器(需自行实现ChatHandler)
// 功能:处理实际的业务消息,例如:
// 1. 文本消息解析与转发
// 2. 二进制消息处理(如图片/语音)
// 3. 用户上下线管理
pipeline.addLast(new ChatHandler());
}
}
/**
* 心跳检测处理器
* 用于处理网络连接的空闲状态检测,自动关闭不活跃的连接以节省资源
*
* 工作原理:
* 1. 由IdleStateHandler触发空闲事件
* 2. 根据不同的空闲状态采取相应措施
* 3. 默认会继续传播事件(除非显式拦截)
*/
@ChannelHandler.Sharable // 标记为可共享处理器(无状态时可安全复用)
public class HeartBeatHandler extends ChannelInboundHandlerAdapter {
/**
* 处理用户事件(由IdleStateHandler触发)
* @param ctx 通道上下文
* @param evt 事件对象
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
// 仅处理空闲状态事件
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
switch (event.state()) {
case READER_IDLE:
// 读空闲(客户端长时间未发送数据)
// 注意:这里只是示例,实际需要根据业务决定是否处理
System.out.println("通道[" + ctx.channel().id() + "]进入读空闲...");
break;
case WRITER_IDLE:
// 写空闲(服务器长时间未发送数据)
System.out.println("通道[" + ctx.channel().id() + "]进入写空闲...");
break;
case ALL_IDLE:
// 读写都空闲(连接完全不活跃)
// 记录关闭前的连接数(调试用)
System.out.println("channel关闭前,clients数量:" + ChatHandler.clients.size());
// 通过Context异步关闭不活跃连接,并添加监听器
ctx.channel().close().addListener(future -> {
if (future.isSuccess()) {
// 可添加关闭成功后的逻辑
System.out.println("channel关闭后,clients数量:" + ChatHandler.clients.size());
}
});
break;
}
}
}
/**
* 异常处理
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// 记录心跳检测过程中的异常
cause.printStackTrace();
ctx.close();
}
}
/**
* OkHttp 工具类
* 提供基于 OkHttp 的 HTTP 请求简化操作
*
* 主要功能:
* 1. 封装 GET 请求基础操作
* 2. 自动处理响应结果转换
*
*/
public class OkHttpUtil {
// 静态常量:单例 OkHttpClient 实例
private static final OkHttpClient CLIENT = new OkHttpClient();
/**
* 执行 GET 请求(与原方法完全兼容)
* @param url 请求地址
* @return Result 或 null(失败时)
*/
public static Result get(String url) {
try {
// 使用单例 CLIENT 替代新建对象
Request request = new Request.Builder()
.get()
.url(url)
.build();
Response response = CLIENT.newCall(request).execute();
String res = response.body().string();
return JsonUtils.jsonToPojo(res, Result.class);
} catch (Exception e) {
System.out.println("OkHttp get failed:", e);
return null;
}
}
}
/**
*
* @Title: JsonUtils.java
* @Package com.imooc.utils
* @Description: json转换类
* Copyright: Copyright (c)
* Company: www.imooc.com
*
*/
public class JsonUtils {
// 定义jackson对象
private static final ObjectMapper MAPPER = new ObjectMapper();
/**
* 将对象转换成json字符串。
* @param data
* @return
*/
public static String objectToJson(Object data) {
try {
String string = MAPPER.writeValueAsString(data);
return string;
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
/**
* 将json结果集转化为对象
*
* @param jsonData json数据
* @param beanType 对象中的object类型
* @return
*/
public static T jsonToPojo(String jsonData, Class beanType) {
try {
T t = MAPPER.readValue(jsonData, beanType);
return t;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 将json数据转换成pojo对象list
* @param jsonData
* @param beanType
* @return
*/
public static List jsonToList(String jsonData, Class beanType) {
JavaType javaType = MAPPER.getTypeFactory().constructParametricType(List.class, beanType);
try {
List list = MAPPER.readValue(jsonData, javaType);
return list;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
/**
* 消息类型枚举
* 定义系统中不同类型的消息常量
*/
public enum MsgTypeEnum {
// 枚举实例定义
CONNECT_INIT(0, "第一次(或重连)初始化连接"), // 连接初始化消息,用于建立或重新建立连接
WORDS(1, "文字表情消息"), // 普通文本或表情消息
IMAGE(2, "图片"), // 图片消息
VOICE(3, "语音"), // 语音消息
VIDEO(4, "视频"), // 视频消息
// 枚举字段
public final Integer type; // 消息类型编码(数字形式)
public final String content; // 消息类型描述
/**
* 枚举构造函数
* @param type 消息类型编码
* @param content 类型描述
*/
MsgTypeEnum(Integer type, String content){
this.type = type;
this.content = content;
}
/**
* 获取消息类型编码
* @return 类型编码(Integer)
*/
public Integer getType() {
return type;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ChatMsg {
private String senderId; // 发送者的用户id
private String receiverId; // 接受者的用户id
private String msg; // 聊天内容
private Integer msgType; // 消息类型,见枚举 MsgTypeEnum.java
private String msgId; // 消息主键id
@JsonDeserialize(using = LocalDateTimeDeserializer.class) // JSON反序列化
@JsonSerialize(using = LocalDateTimeSerializer.class) // JSON序列化
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 日期格式
private LocalDateTime chatTime; // 消息的聊天时间,既是发送者的发送时间、又是接受者的接受时间
private Integer showMsgDateTimeFlag; // 标记存储数据库,用于历史展示。每超过1分钟,则显示聊天时间,前端可以控制时间长短
private String videoPath; // 视频地址
private Integer videoWidth; // 视频宽度
private Integer videoHeight; // 视频高度
private Integer videoTimes; // 视频时间
private String voicePath; // 语音地址
private Integer speakVoiceDuration; // 语音时长
private Boolean isRead; // 语音消息标记是否已读未读,true: 已读,false: 未读
private Integer communicationType; // 聊天类型, 1:单聊,2:群聊
private Boolean isReceiverOnLine; // 用于标记当前接受者是否在线
}
/**
* WebSocket消息处理器
* 功能:处理实时聊天消息,包括文本、图片、语音、视频等多种消息类型
* 核心职责:
* 1. 管理所有客户端连接
* 2. 处理消息路由(单聊/群聊)
* 3. 维护用户-通道的映射关系
* 4. 处理连接生命周期事件
*
* @Auther yang
*/
// 继承SimpleChannelInboundHandler专门处理TextWebSocketFrame类型消息
public class ChatHandler extends SimpleChannelInboundHandler {
/**
* 全局Channel组,管理所有活跃连接
* 特点:
* - 使用Netty提供的DefaultChannelGroup
* - 采用全局事件执行器管理生命周期
*/
public static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
// 在类初始化时创建单例保证线程安全 snowflake主键id生成
private static final Snowflake ID_GENERATOR = new Snowflake(new IdWorkerConfigBean());
/**
* 处理收到的WebSocket文本帧
* @param ctx 通道上下文
* @param msg 文本帧对象(包含消息内容)
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 原始消息内容输出(调试用)
String content = msg.text();
System.out.println("接受到的数据:" + content);
// 消息解析流程
DataContent dataContent = JsonUtils.jsonToPojo(content, DataContent.class);
ChatMsg chatMsg = dataContent.getChatMsg();
// 提取消息要素
String msgText = chatMsg.getMsg();
String receiverId = chatMsg.getReceiverId();
String senderId = chatMsg.getSenderId();
// TODO: 判断双方是否黑名单拉黑 start
// 如果双方只要有一方是黑名单,则终止发送(通过OKHTTP调用其他服务)
Result result = OkHttpUtil.get("http://127.0.0.1:8888/isBlack?friendId1st=" + receiverId
+ "&friendId2nd=" + senderId);
boolean isBlack = (Boolean)result.getData();
System.out.println("当前的黑名单关系为: " + isBlack);
if (isBlack) {
return;
}
// 判断双方是否黑名单拉黑 end
// 统一使用服务器时间
chatMsg.setChatTime(LocalDateTime.now());
Integer msgType = chatMsg.getMsgType();
// 获取当前通道信息
Channel currentChannel = ctx.channel();
String currentChannelId = currentChannel.id().asLongText();
String currentChannelIdShort = currentChannel.id().asShortText();
// System.out.println("客户端currentChannelId:" + currentChannelId);
// System.out.println("客户端currentChannelIdShort:" + currentChannelIdShort);
// 消息类型路由,判断消息类型,根据不同的类型来处理不同的业务
if (msgType == MsgTypeEnum.CONNECT_INIT.type) {
/* 连接初始化处理,当websocket初次open的时候,初始化channel,把channel和用户userid管来起来 */
UserChannelSession.putMultiChannels(senderId, currentChannel);
} else if (msgType == MsgTypeEnum.WORDS.type
|| msgType == MsgTypeEnum.IMAGE.type
|| msgType == MsgTypeEnum.VIDEO.type
|| msgType == MsgTypeEnum.VOICE.type) {
/* 业务消息处理 */
// 生成消息ID(保证分布式唯一性)
// TODO: 此处如果用mq异步解耦,保存信息到数据库,数据库无法获得信息的主键id(无法在此处chat-server获得消息主键id)
// 所以此处可以用snowflake直接生成唯一的主键id传到业务服务直接新增
String sid = ID_GENERATOR.nextId();
System.out.println("sid = " + sid);
chatMsg.setMsgId(sid);
// 接收者通道查询
List receiverChannels = UserChannelSession.getMultiChannels(receiverId);
if (receiverChannels == null || receiverChannels.isEmpty()) {
// 接收方离线处理
chatMsg.setIsReceiverOnLine(false);
} else {
// 接收方在线处理
chatMsg.setIsReceiverOnLine(true);
// 多设备消息投递
for (Channel c : receiverChannels) {
Channel findChannel = clients.find(c.id());
if (findChannel != null) {
// 语音消息特殊标记
if (msgType == MsgTypeEnum.VOICE.type) {
chatMsg.setIsRead(false);
}
// 消息封装与发送
dataContent.setChatMsg(chatMsg);
String chatTimeFormat = LocalDateUtils.format(
chatMsg.getChatTime(),
LocalDateUtils.DATETIME_PATTERN_2);
dataContent.setChatTime(chatTimeFormat);
// 发送消息给在线的用户
findChannel.writeAndFlush(
new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
}
}
}
// TODO: 消息持久化到数据库(通过MQ异步处理或者其他方式)
}
/* 发送方多设备同步(如PC与手机双端) */
List myOtherChannels = UserChannelSession.getMyOtherChannels(
senderId, currentChannelId);
for (Channel c : myOtherChannels) {
Channel findChannel = clients.find(c.id());
if (findChannel != null) {
dataContent.setChatMsg(chatMsg);
String chatTimeFormat = LocalDateUtils.format(
chatMsg.getChatTime(),
LocalDateUtils.DATETIME_PATTERN_2);
dataContent.setChatTime(chatTimeFormat);
findChannel.writeAndFlush(
new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
}
}
// 调试输出当前会话状态
UserChannelSession.outputMulti();
}
/**
* 新连接建立时触发
* 功能:将新通道加入全局管理
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel currentChannel = ctx.channel();
String currentChannelId = currentChannel.id().asLongText();
System.out.println("客户端建立连接,channel对应的长id为:" + currentChannelId);
// 获得客户端的channel,并且存入到ChannelGroup中进行管理(作为一个客户端群组)
clients.add(currentChannel);
}
/**
* 连接关闭时触发
* 功能:清理通道相关资源
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel currentChannel = ctx.channel();
String currentChannelId = currentChannel.id().asLongText();
System.out.println("客户端关闭连接,channel对应的长id为:" + currentChannelId);
// 清理用户-通道映射
String userId = UserChannelSession.getUserIdByChannelId(currentChannelId);
UserChannelSession.removeUselessChannels(userId, currentChannelId);
// 从全局组移除
clients.remove(currentChannel);
}
/**
* 异常处理
* 功能:安全关闭异常连接
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
Channel currentChannel = ctx.channel();
String currentChannelId = currentChannel.id().asLongText();
System.out.println("发生异常捕获,channel对应的长id为:" + currentChannelId);
// 关闭异常连接(关闭channel)
ctx.channel().close();
// 随后从ChannelGroup中移除对应的channel
clients.remove(currentChannel);
// 清理用户会话
String userId = UserChannelSession.getUserIdByChannelId(currentChannelId);
UserChannelSession.removeUselessChannels(userId, currentChannelId);
}
}
/**
* 用户会话管理器(线程安全版)
* 核心功能:
* 1. 维护用户ID与Channel的多对多关系(支持多端登录)
* 2. 提供通道查找和会话管理能力
* 3. 支持设备级消息路由控制
*
* 线程安全实现:
* 1. 使用ConcurrentHashMap保证基础结构的线程安全
* 2. 使用CopyOnWriteArrayList保证Channel列表的线程安全
* 3. 所有写操作都通过原子方法实现
*/
public class UserChannelSession {
/**
* 多设备会话映射表(线程安全实现)
* Key: 用户ID
* Value: 该用户所有活跃的Channel列表(CopyOnWriteArrayList保证线程安全)
*/
private static final ConcurrentMap> multiSession =
new ConcurrentHashMap<>();
/**
* Channel与用户ID的逆向映射(线程安全实现)
* Key: Channel长ID(asLongText)
* Value: 绑定的用户ID
*/
private static final ConcurrentMap userChannelIdRelation =
new ConcurrentHashMap<>();
/**
* 添加用户-Channel关联关系(原子操作)
* @param channelId Channel的长ID(ctx.channel().id().asLongText())
* @param userId 绑定的用户ID
*/
public static void putUserChannelIdRelation(String channelId, String userId) {
userChannelIdRelation.put(channelId, userId);
}
/**
* 通过ChannelID查找用户ID(线程安全)
* @param channelId Channel的长ID
* @return 关联的用户ID,未找到返回null
*/
public static String getUserIdByChannelId(String channelId) {
return userChannelIdRelation.get(channelId);
}
/**
* 添加用户与设备Channel关系(原子操作)
* @param userId 用户ID
* @param channel 新增的Channel对象
*/
public static void putMultiChannels(String userId, Channel channel) {
multiSession.compute(userId, (k, v) -> {
if (v == null) v = new CopyOnWriteArrayList<>();
v.addIfAbsent(channel); // 避免重复添加
return v;
});
// 添加用户-ChannelId关联关系
putUserChannelIdRelation(channel.id().asLongText(), userId);
}
/**
* 获取用户的所有活跃Channel(线程安全快照)
* @param userId 用户ID
* @return Channel列表(可能为null)
*/
public static List getMultiChannels(String userId) {
return multiSession.get(userId);
}
/**
* 移除指定用户的无效Channel(原子操作)
* @param userId 用户ID
* @param channelId 需要移除的Channel长ID
*/
public static void removeUselessChannels(String userId, String channelId) {
multiSession.computeIfPresent(userId, (k, v) -> {
v.removeIf(c -> c.id().asLongText().equals(channelId));
return v.isEmpty() ? null : v; // 自动清理空列表
});
userChannelIdRelation.remove(channelId);
}
/**
* 获取用户其他设备的Channel(线程安全快照)
* @param userId 用户ID
* @param channelId 需要排除的Channel长ID
* @return 其他设备的Channel列表(可能为null)
*/
public static List getMyOtherChannels(String userId, String channelId) {
CopyOnWriteArrayList channels = multiSession.get(userId);
if (channels == null || channels.isEmpty()) {
return null;
}
return channels.stream()
.filter(c -> !c.id().asLongText().equals(channelId))
.collect(Collectors.toList());
}
/**
* 打印当前所有会话状态(线程安全快照)
* 注意:输出期间可能有数据更新,但不影响输出一致性
*/
public static void outputMulti() {
System.out.println("++++++++++++++++++");
multiSession.forEach((userId, channels) -> {
System.out.println("----------");
System.out.println("UserId: " + userId);
channels.forEach(c ->
System.out.println("\t\t ChannelId: " + c.id().asLongText())
);
System.out.println("----------");
});
System.out.println("++++++++++++++++++");
}
}
本文实现的Netty+WebSocket IM通信服务具有以下特点:
高性能:通过Netty的零拷贝、内存池等技术优化
可扩展:支持水平扩展的集群架构
可观测:完善的监控指标和日志记录
轻量级:专注通信核心功能,代码量<2000行
后续演进方向:
添加消息可靠性保障(ACK机制)
实现黑名单处理器、流量限制处理器
SSL处理器保证安全通信
这个实现可作为企业IM系统的基础通信组件,通过REST/RPC接口或OKHTTP与上层或其他业务系统交互,保持架构的清晰边界,如果你有什么其他问题或者改进建议欢迎评论区留言讨论!
【Netty框架全解析】:从基础概念到高阶实践,从零玩转高性能网络框架!-CSDN博客
【Netty+WebSocket详解】WebSocket全双工通信与Netty的高效结合与实战-CSDN博客
【Spring WebSocket详解】Spring WebSocket从入门到实战-CSDN博客