Netty 客户端


以下是 Netty 客户端的详解及代码示例,结合核心概念、开发步骤和实战代码,帮助您快速掌握 Netty 客户端开发。


一、Netty 客户端核心概念

  1. Bootstrap

    • 客户端的启动引导类,用于配置线程模型、通道类型和处理逻辑。
    • 与服务端的 ServerBootstrap 对应,不区分 boss/worker 线程组。
  2. NioSocketChannel

    • 客户端的通道实现类(对应 Java NIO 的 SocketChannel)。
    • 需指定为 .channel(NioSocketChannel.class)
  3. ChannelPipeline

    • 数据处理流水线,通过添加 Handler 实现编解码和业务逻辑。
    • 典型链式结构:LengthFieldDecoder → StringDecoder → 自定义Handler

二、客户端开发步骤详解

  1. 创建 EventLoopGroup
    负责处理 I/O 操作和任务调度:

    EventLoopGroup group = new NioEventLoopGroup();
    
  2. 配置 Bootstrap
    设置线程组、通道类型、处理器链:

    Bootstrap bootstrap = new Bootstrap();
    bootstrap.group(group)
             .channel(NioSocketChannel.class)
             .handler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline()
                       .addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4)) // 解决粘包
                       .addLast(new StringDecoder(CharsetUtil.UTF_8))
                       .addLast(new EchoClientHandler()); // 业务处理器
                 }
             });
    
  3. 发起连接与重连机制

    • 异步连接并监听结果:
      ChannelFuture future = bootstrap.connect("127.0.0.1", 8080).addListener(f -> {
          if (!f.isSuccess()) {
              // 指数退避重连
              int delay = 1 << retryCount;
              group.schedule(() -> connect(bootstrap, retryCount + 1), delay, SECONDS);
          }
      });
      
    • 重连策略:首次失败后延迟 1 秒,后续按 2^n 递增。
  4. 业务处理器示例
    处理数据接收和异常:

    public class EchoClientHandler extends SimpleChannelInboundHandler<String> {
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, String msg) {
            System.out.println("收到服务端响应: " + msg);
        }
    
        @Override
        public void channelActive(ChannelHandlerContext ctx) {
            ctx.writeAndFlush("Hello Server!"); // 连接建立后发送数据
        }
    
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            cause.printStackTrace();
            ctx.close();
        }
    }
    

三、完整客户端代码示例

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.util.CharsetUtil;

public class NettyClient {
    private static final int MAX_RETRY = 3; // 最大重连次数

    public static void main(String[] args) throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                     .channel(NioSocketChannel.class)
                     .handler(new ChannelInitializer<SocketChannel>() {
                         @Override
                         protected void initChannel(SocketChannel ch) {
                             ch.pipeline()
                               .addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4))
                               .addLast(new StringDecoder(CharsetUtil.UTF_8))
                               .addLast(new EchoClientHandler());
                         }
                     });

            // 发起连接(含重试逻辑)
            connect(bootstrap, "127.0.0.1", 8080, MAX_RETRY);
        } finally {
            group.shutdownGracefully();
        }
    }

    private static void connect(Bootstrap bootstrap, String host, int port, int retry) {
        bootstrap.connect(host, port).addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                System.out.println("连接成功!");
            } else if (retry == 0) {
                System.err.println("重试次数耗尽,连接失败");
            } else {
                int delay = 1 << (MAX_RETRY - retry);
                bootstrap.config().group().schedule(
                    () -> connect(bootstrap, host, port, retry - 1), delay, TimeUnit.SECONDS
                );
            }
        });
    }
}

四、高级特性与注意事项

  1. 粘包/半包解决方案

    • LengthFieldBasedFrameDecoder:通过长度字段标识数据包边界(需与服务端协议一致)。

    • 示例配置:.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4))

    • public LengthFieldBasedFrameDecoder(ByteOrder byteOrder, int maxFrameLength, int lengthFieldOffset, int lengthFieldLength, int lengthAdjustment, int initialBytesToStrip, boolean failFast)

    • byteOrder:表示字节流表示的数据是大端还是小端,用于长度域的读取;

    • maxFrameLength:表示发送的数据帧的最大长度。

    • lengthFieldOffset:长度字段在发送的字节数组中的下标位置。也就是说,从字节数组的这个下标开始,是表示长度的字段。

    • lengthFieldLength:长度字段的字节数。

    • lengthAdjustment:长度调整值,用于修正消息长度的计算。例如,如果整个消息包括消息头和消息体,那么这个值可以用来调整只计算消息体的长度。

    • initialBytesToStrip:接收到的数据包开始的这么多字节将被跳过。这可以用来去除消息头部分。

    • failFast:如果为true,则表示读取到长度域,TA的值的超过maxFrameLength,就抛出一个 TooLongFrameException,而为false表示只有当真正读取完长度域的值表示的字节之后,才会抛出 TooLongFrameException,默认情况下设置为true,建议不要修改,否则可能会造成内存溢出。

  2. 连接管理

    • 心跳机制:通过 IdleStateHandler 检测空闲连接并发送心跳包。
    • 资源释放:确保在关闭时调用 group.shutdownGracefully()
  3. 性能调优

    • ChannelOption 参数
      bootstrap.option(ChannelOption.SO_KEEPALIVE, true)   // TCP保活
               .option(ChannelOption.TCP_NODELAY, true);   // 禁用Nagle算法
      
    • I/O 线程数:根据 CPU 核心数设置 NioEventLoopGroup 线程数。

五、常见问题排查

  1. 连接失败不重连
    检查 connect() 是否添加了监听器(Listener)并实现重试逻辑。

  2. 数据接收不完整
    确认 Pipeline 中已添加拆包器(如 LengthFieldBasedFrameDecoder)。

  3. 内存泄漏

    • 确保 ByteBuf 引用计数释放(调用 release() 或使用 SimpleChannelInboundHandler 自动释放)。
    • 使用 Netty 提供的 ResourceLeakDetector 检测泄漏。

netty客户端示例


package com.netty.client;

import com.netty.client.config.TcpClientConfig;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.MessageToByteEncoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;

@Slf4j
@Component
public class NettyClient {
    private final TcpClientConfig config;
    private Channel channel;
    private CompletableFuture<String> currentFuture;
    private final Object lock = new Object();
    private EventLoopGroup workerGroup;
    private Bootstrap bootstrap;

    // 连接状态标志
    private final AtomicBoolean isConnected = new AtomicBoolean(false);
    private final AtomicBoolean reconnecting = new AtomicBoolean(false);

    public NettyClient(TcpClientConfig config) {
        this.config = config;
        initializeNettyResources();
    }

    private void initializeNettyResources() {
        workerGroup = new NioEventLoopGroup();
        bootstrap = new Bootstrap();
        bootstrap.group(workerGroup)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, config.getTimeout())
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(new LengthFieldDecoder());
                        pipeline.addLast(new LengthFieldEncoder());
                        pipeline.addLast(new ClientHandler());
                    }
                });
    }
    //    @Scheduled(cron = "${unisic.automate.mes.cron:*/5 * * * * ?}")
    public void connect() {
        if (reconnecting.compareAndSet(false, true)) {
            try {
                // 检查当前连接状态
                if (isConnected.get() && channel != null && channel.isActive()) {
                    log.info("Already connected to {}:{}", config.getHost(), config.getPort());
                    return;
                }

                // 关闭旧连接(如果存在)
                if (channel != null) {
                    channel.close().sync();
                }

                // 使用同一个bootstrap创建新连接
                ChannelFuture future = bootstrap.connect(config.getHost(), config.getPort());
                future.addListener((ChannelFutureListener) f -> {
                    if (f.isSuccess()) {
                        channel = f.channel();
                        isConnected.set(true);
                        log.info("Connected to {}:{}", config.getHost(), config.getPort());
                    } else {
                        log.error("Connection to {}:{} failed", config.getHost(), config.getPort(), f.cause());
                        scheduleReconnect();
                    }
                });

                // 等待连接完成
                future.sync();
            } catch (Exception e) {
                log.error("Connection attempt failed", e);
                scheduleReconnect();
            } finally {
                reconnecting.set(false);
            }
        }
    }

    private void scheduleReconnect() {
        if (workerGroup != null && !workerGroup.isShutdown()) {
            Integer reconnectDelay = Optional.ofNullable(config).map(TcpClientConfig::getReconnectDelay).orElse(2);
            workerGroup.schedule(this::connect, reconnectDelay, TimeUnit.SECONDS);
            log.warn("Scheduled reconnect in {} seconds", reconnectDelay);
        }
    }

    public String sendAndReadSync(String message)
            throws InterruptedException, TimeoutException {
        synchronized (lock) {
            // 检查连接状态
            if (!isConnected.get() || channel == null || !channel.isActive()) {
                throw new IllegalStateException("Not connected to server");
            }

            if (currentFuture != null) {
                throw new IllegalStateException("Another request is in progress");
            }

            currentFuture = new CompletableFuture<>();
            channel.writeAndFlush(message).addListener(f -> {
                if (!f.isSuccess() && currentFuture != null) {
                    currentFuture.completeExceptionally(f.cause());
                }
            });

            try {
                Integer requestTimeout = Optional.ofNullable(config).map(TcpClientConfig::getRequestTimeout).orElse(3);;
                return currentFuture.get(requestTimeout, TimeUnit.MILLISECONDS);
            } catch (Exception e) {
                currentFuture = null;
                if (e instanceof TimeoutException) {
                    throw (TimeoutException) e;
                }
                throw new RuntimeException("Request failed", e);
            } finally {
                currentFuture = null;
            }
        }
    }

    public void shutdown() {
        if (channel != null) {
            channel.close().awaitUninterruptibly();
            isConnected.set(false);
        }
        if (workerGroup != null) {
            workerGroup.shutdownGracefully();
        }
    }

    // 获取当前连接状态
    public boolean isConnected() {
        return isConnected.get() && channel != null && channel.isActive();
    }

    static class LengthFieldDecoder extends ByteToMessageDecoder {
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
            if (in.readableBytes() < 4) return;

            in.markReaderIndex();
            int length = in.readIntLE();
            if (in.readableBytes() < length) {
                in.resetReaderIndex();
                return;
            }

            ByteBuf payload = in.readBytes(length);
            out.add(payload.toString(StandardCharsets.UTF_8));
        }
    }

    static class LengthFieldEncoder extends MessageToByteEncoder<String> {
        @Override
        protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) {
            byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
            out.writeIntLE(bytes.length);
            out.writeBytes(bytes);
        }
    }

    class ClientHandler extends SimpleChannelInboundHandler<String> {
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, String msg) {
            synchronized (lock) {
                if (currentFuture != null) {
                    currentFuture.complete(msg);
                }
            }
        }

        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            log.error("Channel exception: ", cause);
            synchronized (lock) {
                if (currentFuture != null) {
                    currentFuture.completeExceptionally(cause);
                }
            }
            isConnected.set(false);
            ctx.close();
        }

        @Override
        public void channelInactive(ChannelHandlerContext ctx) {
            log.warn("Connection lost to {}:{}", config.getHost(), config.getPort());
            isConnected.set(false);
            if (!reconnecting.get()) {
                scheduleReconnect();
            }
        }
    }
}

netty多线程

package com.netty.client;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.base.exception.ServiceException;
import com.netty.client.config.TcpClientConfig;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.CorruptedFrameException;
import io.netty.handler.codec.MessageToByteEncoder;
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import io.netty.util.Timer;
import io.netty.util.TimerTask;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.pool2.BasePooledObjectFactory;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.stereotype.Component;

import javax.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
@Component
public class NettyTcpClient {
    private final GenericObjectPool<Channel> connectionPool;
    private final TcpClientConfig config;
    private final NioEventLoopGroup workerGroup;
    private final Timer timer = new HashedWheelTimer();

    // 线程安全的请求ID生成器
    private final AtomicInteger requestIdCounter = new AtomicInteger(0);

    public NettyTcpClient(TcpClientConfig config) {
        this.config = config;
        this.workerGroup = new NioEventLoopGroup(config.getThreadCount());
        this.connectionPool = new GenericObjectPool<>(
                new ChannelFactory(config, workerGroup),
                buildPoolConfig(config.getPool())
        );
        preWarmPool();
    }

    private void preWarmPool() {
        int minIdle = config.getPool().getMinIdle();
        for (int i = 0; i < minIdle; i++) {
            workerGroup.submit(() -> {
                try {
                    connectionPool.addObject();
                } catch (Exception e) {
                    log.warn("Pre-warm connection failed", e);
                }
            });
        }
    }

    private GenericObjectPoolConfig<Channel> buildPoolConfig(TcpClientConfig.Pool poolConfig) {
        GenericObjectPoolConfig<Channel> config = new GenericObjectPoolConfig<>();
        config.setMaxTotal(poolConfig.getMaxTotal());
        config.setMaxIdle(poolConfig.getMaxIdle());
        config.setMinIdle(poolConfig.getMinIdle());
        config.setTestOnBorrow(true);
        config.setTestOnReturn(true);
        return config;
    }

    public String sendSync(String message) {
        Channel channel = null;
        try {
            channel = connectionPool.borrowObject();

            // 确保消息有唯一的TransactionID
            JSONObject jsonObject = ensureTransactionId(message);
            String correlationId = jsonObject.getJSONObject("Header").getString("TransactionID");

            // 获取处理器并准备响应
            ClientHandler handler = channel.pipeline().get(ClientHandler.class);
            if (handler == null) {
                throw new ServiceException("ClientHandler not found in pipeline");
            }

            CompletableFuture<String> future = handler.prepareResponse(correlationId);
            ChannelFuture writeFuture = channel.writeAndFlush(message);

            // 等待消息发送完成
            if (!writeFuture.await(config.getWriteTimeoutMillis())) {
                throw new ServiceException("Write operation timed out");
            }

            if (!writeFuture.isSuccess()) {
                throw new ServiceException("Write failed", writeFuture.cause());
            }

            return future.get(config.getRequestTimeoutMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            throw new ServiceException("Request timed out");
        } catch (Exception e) {
            throw new ServiceException("Netty communication failed");
        } finally {
            if (channel != null) {
                try {
                    connectionPool.returnObject(channel);
                } catch (Exception e) {
                    log.warn("Return channel to pool failed", e);
                    invalidateChannel(channel);
                }
            }
        }
    }

    private JSONObject ensureTransactionId(String message) {
        JSONObject jsonObject = JSON.parseObject(message);
        JSONObject header = jsonObject.getJSONObject("Header");
        if (!header.containsKey("TransactionID")) {
            header.put("TransactionID", generateTransactionId());
            return jsonObject;
        }
        return jsonObject;
    }

    private String generateTransactionId() {
        return String.format("%s-%d-%d",
                config.getClientId(),
                System.currentTimeMillis(),
                requestIdCounter.incrementAndGet());
    }

    private void invalidateChannel(Channel channel) {
        try {
            connectionPool.invalidateObject(channel);
        } catch (Exception ex) {
            log.warn("Invalidate channel failed", ex);
        }
    }

    @PreDestroy
    public void shutdown() {
        try {
            connectionPool.close();
        } catch (Exception e) {
            log.warn("Connection pool close failed", e);
        }

        if (workerGroup != null) {
            workerGroup.shutdownGracefully(
                    config.getShutdownQuietPeriodMillis(),
                    config.getShutdownTimeoutMillis(),
                    TimeUnit.MILLISECONDS
            ).syncUninterruptibly();
        }

        timer.stop();
    }

    private static class ChannelFactory extends BasePooledObjectFactory<Channel> {
        private final Bootstrap bootstrap;
        private final TcpClientConfig config;

        public ChannelFactory(TcpClientConfig config, EventLoopGroup workerGroup) {
            this.config = config;
            this.bootstrap = new Bootstrap()
                    .group(workerGroup)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, config.getConnectTimeoutMillis())
                    .option(ChannelOption.SO_KEEPALIVE, true)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .remoteAddress(config.getHost(), config.getPort());
        }

        @Override
        public Channel create() throws Exception {
            CompletableFuture<Channel> channelFuture = new CompletableFuture<>();
            ClientHandler handler = new ClientHandler();

            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) {
                    ChannelPipeline pipeline = ch.pipeline();
                    pipeline.addLast(new LengthFieldDecoder());
                    pipeline.addLast(new LengthFieldEncoder());
                    pipeline.addLast(handler);
                }
            });

            ChannelFuture future = bootstrap.connect();
            future.addListener((ChannelFutureListener) f -> {
                if (f.isSuccess()) {
                    channelFuture.complete(f.channel());
                } else {
                    channelFuture.completeExceptionally(f.cause());
                }
            });

            return channelFuture.get(config.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS);
        }

        @Override
        public PooledObject<Channel> wrap(Channel channel) {
            return new DefaultPooledObject<>(channel);
        }

        @Override
        public boolean validateObject(PooledObject<Channel> p) {
            Channel channel = p.getObject();
            return channel.isActive() && channel.isWritable();
        }

        @Override
        public void destroyObject(PooledObject<Channel> p) {
            Channel channel = p.getObject();
            if (channel.isActive()) {
                channel.close().awaitUninterruptibly();
            }
        }
    }

    private static class LengthFieldDecoder extends ByteToMessageDecoder {
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
            if (in.readableBytes() < 4) return;

            in.markReaderIndex();
            int length = in.readIntLE();

            // 长度校验
            if (length <= 0 || length > 10 * 1024 * 1024) { // 10MB max
                in.resetReaderIndex();
                throw new CorruptedFrameException("Invalid frame length: " + length);
            }

            if (in.readableBytes() < length) {
                in.resetReaderIndex();
                return;
            }

            ByteBuf payload = in.readBytes(length);
            out.add(payload.toString(StandardCharsets.UTF_8));
        }
    }

    private static class LengthFieldEncoder extends MessageToByteEncoder<String> {
        @Override
        protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) {
            byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
            out.writeIntLE(bytes.length);
            out.writeBytes(bytes);
        }
    }

    private class ClientHandler extends SimpleChannelInboundHandler<String> {
        private final Map<String, CompletableFuture<String>> pendingRequests = new ConcurrentHashMap<>();

        @Override
        protected void channelRead0(ChannelHandlerContext ctx, String msg) {
            try {
                JSONObject jsonObject = JSON.parseObject(msg);
                JSONObject header = jsonObject.getJSONObject("Header");
                String correlationId = header.getString("TransactionID");

                CompletableFuture<String> future = pendingRequests.remove(correlationId);
                if (future != null) {
                    future.complete(msg);
                } else {
                    log.warn("Received response for unknown TransactionID: {}", correlationId);
                }
            } catch (Exception e) {
                log.error("Message processing failed: {}", e.getMessage());
                log.debug("Original message: {}", msg);
            }
        }

        public CompletableFuture<String> prepareResponse(String correlationId) {
            CompletableFuture<String> future = new CompletableFuture<>();
            pendingRequests.put(correlationId, future);

            // 使用Netty的HashedWheelTimer进行超时管理
            Timeout timeout = timer.newTimeout(new TimerTask() {
                @Override
                public void run(Timeout timeout) {
                    if (pendingRequests.remove(correlationId) != null) {
                        future.completeExceptionally(new TimeoutException("Response timeout"));
                    }
                }
            }, config.getRequestTimeoutMillis(), TimeUnit.MILLISECONDS);

            future.whenComplete((r, t) -> {
                timeout.cancel();
                pendingRequests.remove(correlationId);
            });

            return future;
        }

        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            log.error("Channel error: {}", cause.getMessage(), cause);

            // 失败所有pending请求
            pendingRequests.values().forEach(f ->
                    f.completeExceptionally(new ServiceException("Channel closed due to error")));
            pendingRequests.clear();

            ctx.close();
        }

        @Override
        public void channelInactive(ChannelHandlerContext ctx) {
            log.warn("Channel inactive: {}", ctx.channel());

            // 失败所有pending请求
            pendingRequests.values().forEach(f ->
                    f.completeExceptionally(new ServiceException("Channel closed")));
            pendingRequests.clear();
        }
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值