Dubbo Consumer 編解碼過程

Dubbo 協(xié)議

協(xié)議概覽

Dubbo 框架定義了私有的RPC協(xié)議,其中請(qǐng)求和響應(yīng)協(xié)議的具體內(nèi)容我們使用表格來展示。


/dev-guide/images/dubbo_protocol_header.jpg

協(xié)議詳情

  • Magic - Magic High & Magic Low (16 bits)

    標(biāo)識(shí)協(xié)議版本號(hào),Dubbo 協(xié)議:0xdabb

  • Req/Res (1 bit)

    標(biāo)識(shí)是請(qǐng)求或響應(yīng)。請(qǐng)求: 1; 響應(yīng): 0。

  • 2 Way (1 bit)

    僅在 Req/Res 為1(請(qǐng)求)時(shí)才有用,標(biāo)記是否期望從服務(wù)器返回值。如果需要來自服務(wù)器的返回值,則設(shè)置為1。

  • Event (1 bit)

    標(biāo)識(shí)是否是事件消息,例如,心跳事件。如果這是一個(gè)事件,則設(shè)置為1。

  • Serialization ID (5 bit)

    標(biāo)識(shí)序列化類型:比如 fastjson 的值為6。

  • Status (8 bits)

    僅在 Req/Res 為0(響應(yīng))時(shí)有用,用于標(biāo)識(shí)響應(yīng)的狀態(tài)。

    • 20 - OK
    • 30 - CLIENT_TIMEOUT
    • 31 - SERVER_TIMEOUT
    • 40 - BAD_REQUEST
    • 50 - BAD_RESPONSE
    • 60 - SERVICE_NOT_FOUND
    • 70 - SERVICE_ERROR
    • 80 - SERVER_ERROR
    • 90 - CLIENT_ERROR
    • 100 - SERVER_THREADPOOL_EXHAUSTED_ERROR
  • Request ID (64 bits)

    標(biāo)識(shí)唯一請(qǐng)求。類型為long。

  • Data Length (32 bits)

    序列化后的內(nèi)容長度(可變部分),按字節(jié)計(jì)數(shù)。int類型。

  • Variable Part

    被特定的序列化類型(由序列化 ID 標(biāo)識(shí))序列化后,每個(gè)部分都是一個(gè) byte [] 或者 byte

    • 如果是請(qǐng)求包 ( Req/Res = 1),則每個(gè)部分依次為:
      • Dubbo version
      • Service name
      • Service version
      • Method name
      • Method parameter types
      • Method arguments
      • Attachments
    • 如果是響應(yīng)包(Req/Res = 0),則每個(gè)部分依次為:
      • 返回值類型(byte),標(biāo)識(shí)從服務(wù)器端返回的值類型:
        • 返回空值:RESPONSE_NULL_VALUE 2
        • 正常響應(yīng)值: RESPONSE_VALUE 1
        • 異常:RESPONSE_WITH_EXCEPTION 0
      • 返回值:從服務(wù)端返回的響應(yīng)bytes


源碼分析

DubboCodec類圖

  • DubboCodec的類關(guān)系如上圖,核心的邏輯在ExchangeCodec和DubboCodec。

NettyClient

public class NettyClient extends AbstractClient {

    private static final NioEventLoopGroup nioEventLoopGroup = new NioEventLoopGroup(Constants.DEFAULT_IO_THREADS, new DefaultThreadFactory("NettyClientWorker", true));
    private Bootstrap bootstrap;
    private volatile Channel channel; // volatile, please copy reference to use

    public NettyClient(final URL url, final ChannelHandler handler) throws RemotingException {
        super(url, wrapChannelHandler(url, handler));
    }

    @Override
    protected void doOpen() throws Throwable {
        final NettyClientHandler nettyClientHandler = new NettyClientHandler(getUrl(), this);
        bootstrap = new Bootstrap();
        bootstrap.group(nioEventLoopGroup)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.TCP_NODELAY, true)
                .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
                //.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, getTimeout())
                .channel(NioSocketChannel.class);

        if (getConnectTimeout() < 3000) {
            bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000);
        } else {
            bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, getConnectTimeout());
        }

        bootstrap.handler(new ChannelInitializer() {

            @Override
            protected void initChannel(Channel ch) throws Exception {
                NettyCodecAdapter adapter = new NettyCodecAdapter(getCodec(), getUrl(), NettyClient.this);
                ch.pipeline()//.addLast("logging",new LoggingHandler(LogLevel.INFO))//for debug
                        .addLast("decoder", adapter.getDecoder())
                        // NettyCodecAdapter.getEncoder()返回InternalEncoder對(duì)象
                        .addLast("encoder", adapter.getEncoder())
                        .addLast("handler", nettyClientHandler);
            }
        });
    }
}
  • NettyClient的NettyCodecAdapter對(duì)象,在編碼過程中會(huì)使用adapter.getEncoder(),在解碼過程中會(huì)使用adapter.getDecoder()。
  • 核心繼續(xù)關(guān)注下NettyCodecAdapter。


NettyCodecAdapter

final class NettyCodecAdapter {

    private final ChannelHandler encoder = new InternalEncoder();
    private final ChannelHandler decoder = new InternalDecoder();
    // DubboCountCodec對(duì)象
    private final Codec2 codec;
    private final URL url;
    private final com.alibaba.dubbo.remoting.ChannelHandler handler;

    public NettyCodecAdapter(Codec2 codec, URL url, com.alibaba.dubbo.remoting.ChannelHandler handler) {
        this.codec = codec;
        this.url = url;
        this.handler = handler;
    }

    public ChannelHandler getEncoder() {
        return encoder;
    }

    public ChannelHandler getDecoder() {
        return decoder;
    }

    private class InternalEncoder extends MessageToByteEncoder {

        @Override
        protected void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) throws Exception {
            com.alibaba.dubbo.remoting.buffer.ChannelBuffer buffer = new NettyBackedChannelBuffer(out);
            Channel ch = ctx.channel();
            NettyChannel channel = NettyChannel.getOrAddChannel(ch, url, handler);
            try {
                // DubboCountCodec對(duì)象
                codec.encode(channel, buffer, msg);
            } finally {
                NettyChannel.removeChannelIfDisconnected(ch);
            }
        }
    }

    private class InternalDecoder extends ByteToMessageDecoder {

        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> out) throws Exception {

            ChannelBuffer message = new NettyBackedChannelBuffer(input);
            NettyChannel channel = NettyChannel.getOrAddChannel(ctx.channel(), url, handler);
            Object msg;
            int saveReaderIndex;

            try {
                // decode object.
                do {
                    saveReaderIndex = message.readerIndex();
                    try {
                        // DubboCountCodec對(duì)象
                        msg = codec.decode(channel, message);
                    } catch (IOException e) {
                        throw e;
                    }
                    if (msg == Codec2.DecodeResult.NEED_MORE_INPUT) {
                        message.readerIndex(saveReaderIndex);
                        break;
                    } else {
                        //is it possible to go here ?
                        if (saveReaderIndex == message.readerIndex()) {
                            throw new IOException("Decode without read data.");
                        }
                        if (msg != null) {
                            out.add(msg);
                        }
                    }
                } while (message.readable());
            } finally {
                NettyChannel.removeChannelIfDisconnected(ctx.channel());
            }
        }
    }
}
  • NettyCodecAdapter包含編碼對(duì)象InternalEncoder。
  • NettyCodecAdapter包含解碼對(duì)象InternalDecoder。
  • NettyCodecAdapter的codec對(duì)象為DubboCountCodec。


Consumer編碼過程

  • Dubbo Client Consumer請(qǐng)求編碼調(diào)用棧
    => NettyCodecAdapter.InternalEncoder.encode
    => DubboCountCodec.encode
    => ExchangeCodec.encode
    => ExchangeCodec.encodeRequest
    => DubboCodec.encodeRequestData


DubboCountCodec

dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboCountCodec

public final class DubboCountCodec implements Codec2 {

    private DubboCodec codec = new DubboCodec();

    @Override
    public void encode(Channel channel, ChannelBuffer buffer, Object msg) throws IOException {
        // DubboCodec對(duì)象
        codec.encode(channel, buffer, msg);
    }
}
  • DubboCountCodec調(diào)用DubboCodec的encode()方法進(jìn)行編碼。
  • DubboCodec的encode()調(diào)用的父類ExchangeCodec的encode()方法進(jìn)行編碼。


ExchangeCodec

public class ExchangeCodec extends TelnetCodec {

    // header length.
    protected static final int HEADER_LENGTH = 16;
    // magic header.
    protected static final short MAGIC = (short) 0xdabb;
    protected static final byte MAGIC_HIGH = Bytes.short2bytes(MAGIC)[0];
    protected static final byte MAGIC_LOW = Bytes.short2bytes(MAGIC)[1];
    // message flag.
    protected static final byte FLAG_REQUEST = (byte) 0x80;
    protected static final byte FLAG_TWOWAY = (byte) 0x40;
    protected static final byte FLAG_EVENT = (byte) 0x20;
    protected static final int SERIALIZATION_MASK = 0x1f;

    public void encode(Channel channel, ChannelBuffer buffer, Object msg) throws IOException {
        if (msg instanceof Request) {
            encodeRequest(channel, buffer, (Request) msg);
        } else if (msg instanceof Response) {
            encodeResponse(channel, buffer, (Response) msg);
        } else {
            super.encode(channel, buffer, msg);
        }
    }

    protected void encodeRequest(Channel channel, ChannelBuffer buffer, Request req) throws IOException {
        // 獲取序列化的對(duì)象
        // hessian2=com.alibaba.dubbo.common.serialize.hessian2.Hessian2Serialization
        Serialization serialization = getSerialization(channel);
        // header. HEADER_LENGTH = 16;
        // 頭部的長度為16個(gè)字節(jié)
        byte[] header = new byte[HEADER_LENGTH];

        // 第1-2個(gè)字節(jié):一個(gè)魔數(shù)數(shù)字(就是一個(gè)固定的數(shù)字)
        Bytes.short2bytes(MAGIC, header);

        // 第3個(gè)字節(jié):雙向或單向的標(biāo)記。
        // 雙向是指請(qǐng)求有去有回,有返回值。單向是指請(qǐng)求有去無回,沒有返回值。
        header[2] = (byte) (FLAG_REQUEST | serialization.getContentTypeId());
        if (req.isTwoWay()) header[2] |= FLAG_TWOWAY;
        if (req.isEvent()) header[2] |= FLAG_EVENT;

        // 第4個(gè)字節(jié):在request請(qǐng)求中空著
        // 第5-12個(gè)字節(jié):請(qǐng)求id,long型的8個(gè)字節(jié)。
        // 異步變同步的全局唯一ID,用來做Consumer和Provider的來回通信標(biāo)記。
        Bytes.long2bytes(req.getId(), header, 4);

        // 編碼請(qǐng)求數(shù)據(jù)
        int savedWriteIndex = buffer.writerIndex();
        // 從HEADER_LENGTH第17個(gè)字節(jié)的位置開始寫請(qǐng)求數(shù)據(jù)
        buffer.writerIndex(savedWriteIndex + HEADER_LENGTH);
        ChannelBufferOutputStream bos = new ChannelBufferOutputStream(buffer);
        ObjectOutput out = serialization.serialize(channel.getUrl(), bos);
        if (req.isEvent()) {
            encodeEventData(channel, out, req.getData());
        } else {
            encodeRequestData(channel, out, req.getData(), req.getVersion());
        }
        out.flushBuffer();
        if (out instanceof Cleanable) {
            ((Cleanable) out).cleanup();
        }
        bos.flush();
        bos.close();
        int len = bos.writtenBytes();
        // 檢查請(qǐng)求大小是否超過限制,最大8M
        checkPayload(channel, len);

        // 第13-16個(gè)字節(jié):消息體長度,請(qǐng)求數(shù)據(jù)的長度。
        Bytes.int2bytes(len, header, 12);

        // write
        buffer.writerIndex(savedWriteIndex);
        buffer.writeBytes(header); // 寫入頭部數(shù)據(jù)
        // 設(shè)置buffer的下一次位置,跳過頭部數(shù)據(jù)和實(shí)體數(shù)據(jù)
        buffer.writerIndex(savedWriteIndex + HEADER_LENGTH + len);
    }
}
  • ExchangeCodec的encode()實(shí)際執(zhí)行的是encodeRequest()方法。


DubboCodec

public class DubboCodec extends ExchangeCodec implements Codec2 {

    @Override
    protected void encodeRequestData(Channel channel, ObjectOutput out, Object data, String version) throws IOException {
        RpcInvocation inv = (RpcInvocation) data;
        // 版本號(hào)
        out.writeUTF(version);
        // 服務(wù)路徑
        out.writeUTF(inv.getAttachment(Constants.PATH_KEY));

        out.writeUTF(inv.getAttachment(Constants.VERSION_KEY));
        // 方法名
        out.writeUTF(inv.getMethodName());
        // 方法參數(shù)類型
        out.writeUTF(ReflectUtils.getDesc(inv.getParameterTypes()));
        // 方法參數(shù)值
        Object[] args = inv.getArguments();
        if (args != null)
            for (int i = 0; i < args.length; i++) {
                out.writeObject(encodeInvocationArgument(channel, inv, i));
            }
        // 方法透傳數(shù)據(jù)
        out.writeObject(inv.getAttachments());
    }
}
  • 在對(duì)請(qǐng)求進(jìn)行編碼時(shí),會(huì)把版本號(hào)、服務(wù)路徑、方法名、方法參數(shù)等信息都進(jìn)行編碼,其中參數(shù)類型是用JVM中的類型表示方法來編碼的。


Consumer解碼過程

  • Dubbo Client Consumer響應(yīng)解碼調(diào)用棧
    => NettyCodecAdapter.InternalDecoder. decode
    =>DubboCountCodec.decode
    =>ExchangeCodec.decode
    =>DubboCodec.decodeBody
    =>DecodeableRPCResult.decode


DubboCountCodec

dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboCountCodec

public final class DubboCountCodec implements Codec2 {

    private DubboCodec codec = new DubboCodec();

    @Override
    public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
        int save = buffer.readerIndex();
        MultiMessage result = MultiMessage.create();
        do {
            // DubboCodec對(duì)象
            Object obj = codec.decode(channel, buffer);
            if (Codec2.DecodeResult.NEED_MORE_INPUT == obj) {
                buffer.readerIndex(save);
                break;
            } else {
                result.addMessage(obj);
                logMessageLength(obj, buffer.readerIndex() - save);
                save = buffer.readerIndex();
            }
        } while (true);
        if (result.isEmpty()) {
            return Codec2.DecodeResult.NEED_MORE_INPUT;
        }
        if (result.size() == 1) {
            return result.get(0);
        }
        return result;
    }
}
  • DubboCountCodec.decode()執(zhí)行DubboCodec.decode()解碼。
  • DubboCodec.decode()執(zhí)行父類的ExchangeCodec.decode()。


ExchangeCodec

public class ExchangeCodec extends TelnetCodec {

    // header length.
    protected static final int HEADER_LENGTH = 16;
    // magic header.
    protected static final short MAGIC = (short) 0xdabb;
    protected static final byte MAGIC_HIGH = Bytes.short2bytes(MAGIC)[0];
    protected static final byte MAGIC_LOW = Bytes.short2bytes(MAGIC)[1];
    // message flag.
    protected static final byte FLAG_REQUEST = (byte) 0x80;
    protected static final byte FLAG_TWOWAY = (byte) 0x40;
    protected static final byte FLAG_EVENT = (byte) 0x20;
    protected static final int SERIALIZATION_MASK = 0x1f;

    @Override
    public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
        int readable = buffer.readableBytes();
        byte[] header = new byte[Math.min(readable, HEADER_LENGTH)];
        buffer.readBytes(header);
        return decode(channel, buffer, readable, header);
    }

    @Override
    protected Object decode(Channel channel, ChannelBuffer buffer, int readable, byte[] header) throws IOException {
        // 檢查魔數(shù),如果不是dubbo協(xié)議,則使用父類的解碼方法
        if (readable > 0 && header[0] != MAGIC_HIGH
                || readable > 1 && header[1] != MAGIC_LOW) {
            int length = header.length;
            if (header.length < readable) {
                header = Bytes.copyOf(header, readable);
                buffer.readBytes(header, length, readable - length);
            }
            for (int i = 1; i < header.length - 1; i++) {
                if (header[i] == MAGIC_HIGH && header[i + 1] == MAGIC_LOW) {
                    buffer.readerIndex(buffer.readerIndex() - header.length + i);
                    header = Bytes.copyOf(header, i);
                    break;
                }
            }
            return super.decode(channel, buffer, readable, header);
        }

        // dubbo 協(xié)議解析
        if (readable < HEADER_LENGTH) {
            return DecodeResult.NEED_MORE_INPUT;
        }

        // 獲取數(shù)據(jù)的實(shí)際長度
        int len = Bytes.bytes2int(header, 12);
        checkPayload(channel, len);

        // 實(shí)際數(shù)據(jù)包長度 = 頭部長度 + 數(shù)據(jù)長度
        int tt = len + HEADER_LENGTH;
        if (readable < tt) {
            return DecodeResult.NEED_MORE_INPUT;
        }

        // limit input stream.
        ChannelBufferInputStream is = new ChannelBufferInputStream(buffer, len);

        try {
            return decodeBody(channel, is, header);
        } finally {
           // 省略相關(guān)代碼
        }
    }
}


DubboCodec

public class DubboCodec extends ExchangeCodec implements Codec2 {

    @Override
    protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
        // 讀取消息標(biāo)記為和協(xié)議類型
        byte flag = header[2], proto = (byte) (flag & SERIALIZATION_MASK);
        // 讀取請(qǐng)求id
        long id = Bytes.bytes2long(header, 4);
        if ((flag & FLAG_REQUEST) == 0) {
            // 客戶端對(duì)響應(yīng)進(jìn)行解碼
            Response res = new Response(id);
            if ((flag & FLAG_EVENT) != 0) {
                res.setEvent(Response.HEARTBEAT_EVENT);
            }
            // 讀取響應(yīng)狀態(tài)
            byte status = header[3];
            res.setStatus(status);
            try {
                if (status == Response.OK) {
                    Object data;
                    if (res.isHeartbeat()) {
                        data = decodeHeartbeatData(channel,  CodecSupport.deserialize(channel.getUrl(), is, proto));
                    } else if (res.isEvent()) {
                        data = decodeEventData(channel,  CodecSupport.deserialize(channel.getUrl(), is, proto));
                    } else {
                        DecodeableRpcResult result;
                        // 根據(jù)decode.in.io的配置決定何時(shí)進(jìn)行解碼
                        if (channel.getUrl().getParameter(
                                Constants.DECODE_IN_IO_THREAD_KEY,
                                Constants.DEFAULT_DECODE_IN_IO_THREAD)) {
                            result = new DecodeableRpcResult(channel, res, is,
                                    (Invocation) getRequestData(id), proto);
                            // 執(zhí)行解碼
                            result.decode();
                        } else {
                            // 解碼相應(yīng)的響應(yīng)值
                            result = new DecodeableRpcResult(channel, res,
                                    new UnsafeByteArrayInputStream(readMessageData(is)),
                                    (Invocation) getRequestData(id), proto);
                        }
                        data = result;
                    }
                    res.setResult(data);
                } else {
                    res.setErrorMessage(CodecSupport.deserialize(channel.getUrl(), is, proto).readUTF());
                }
            } catch (Throwable t) {
                // 省略其他的代碼
            return res;
        } else {
            // decode request.
            // 省略其他的代碼
        }
    }
}


DecodeableRpcResult

public class DecodeableRpcResult extends RpcResult implements Codec, Decodeable {

    private Channel channel;
    private byte serializationType;
    private InputStream inputStream;
    private Response response;
    private Invocation invocation;
    private volatile boolean hasDecoded;

    public DecodeableRpcResult(Channel channel, Response response, InputStream is, Invocation invocation, byte id) {
        Assert.notNull(channel, "channel == null");
        Assert.notNull(response, "response == null");
        Assert.notNull(is, "inputStream == null");
        this.channel = channel;
        this.response = response;
        this.inputStream = is;
        this.invocation = invocation;
        this.serializationType = id;
    }

    @Override
    public Object decode(Channel channel, InputStream input) throws IOException {
        // 獲取序列化對(duì)象
        ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), serializationType)
                .deserialize(channel.getUrl(), input);
        
        byte flag = in.readByte();
        switch (flag) {
            case DubboCodec.RESPONSE_NULL_VALUE:
                break;
            case DubboCodec.RESPONSE_VALUE:
                try {
                    Type[] returnType = RpcUtils.getReturnTypes(invocation);
                    setValue(returnType == null || returnType.length == 0 ? in.readObject() :
                            (returnType.length == 1 ? in.readObject((Class<?>) returnType[0])
                                    : in.readObject((Class<?>) returnType[0], returnType[1])));
                } catch (ClassNotFoundException e) {
                    throw new IOException(StringUtils.toString("Read response data failed.", e));
                }
                break;
            case DubboCodec.RESPONSE_WITH_EXCEPTION:
                try {
                    Object obj = in.readObject();
                    if (obj instanceof Throwable == false)
                        throw new IOException("Response data error, expect Throwable, but get " + obj);
                    setException((Throwable) obj);
                } catch (ClassNotFoundException e) {
                    throw new IOException(StringUtils.toString("Read response data failed.", e));
                }
                break;
            case DubboCodec.RESPONSE_NULL_VALUE_WITH_ATTACHMENTS:
                try {
                    setAttachments((Map<String, String>) in.readObject(Map.class));
                } catch (ClassNotFoundException e) {
                    throw new IOException(StringUtils.toString("Read response data failed.", e));
                }
                break;
            case DubboCodec.RESPONSE_VALUE_WITH_ATTACHMENTS:
                try {
                    Type[] returnType = RpcUtils.getReturnTypes(invocation);
                    setValue(returnType == null || returnType.length == 0 ? in.readObject() :
                            (returnType.length == 1 ? in.readObject((Class<?>) returnType[0])
                                    : in.readObject((Class<?>) returnType[0], returnType[1])));
                    setAttachments((Map<String, String>) in.readObject(Map.class));
                } catch (ClassNotFoundException e) {
                    throw new IOException(StringUtils.toString("Read response data failed.", e));
                }
                break;
            case DubboCodec.RESPONSE_WITH_EXCEPTION_WITH_ATTACHMENTS:
                try {
                    Object obj = in.readObject();
                    if (obj instanceof Throwable == false)
                        throw new IOException("Response data error, expect Throwable, but get " + obj);
                    setException((Throwable) obj);
                    setAttachments((Map<String, String>) in.readObject(Map.class));
                } catch (ClassNotFoundException e) {
                    throw new IOException(StringUtils.toString("Read response data failed.", e));
                }
                break;
            default:
                throw new IOException("Unknown result flag, expect '0' '1' '2' '3' '4' '5', get " + flag);
        }
        if (in instanceof Cleanable) {
            ((Cleanable) in).cleanup();
        }
        return this;
    }

    @Override
    public void decode() throws Exception {
        if (!hasDecoded && channel != null && inputStream != null) {
            try {
                decode(channel, inputStream);
            } catch (Throwable e) {
                if (log.isWarnEnabled()) {
                    log.warn("Decode rpc result failed: " + e.getMessage(), e);
                }
                response.setStatus(Response.CLIENT_ERROR);
                response.setErrorMessage(StringUtils.toString(e));
            } finally {
                hasDecoded = true;
            }
        }
    }

}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容