精盡 Dubbo 源碼分析 —— 註冊中心 之 Redis

1.概述

看下 《Dubbo 用戶指南 —— Redis 註冊中心》 文檔,內容如下:
基於 Redis實現的註冊中心 :

在這裏插入圖片描述
使用 Redis 的 Key/Map 結構存儲數據結構:

  • 主 Key 爲服務名和類型
  • Map 中的 Key 爲 URL 地址
  • Map 中的 Value 爲過期時間,用於判斷髒數據,髒數據由監控中心刪除

類圖如下:
在這裏插入圖片描述

2. RedisRegistry

2.1 屬性和構造方法

/**
     * 默認端口
     */
    private static final int DEFAULT_REDIS_PORT = 6379;
    /**
     * 默認 Redis 根節點
     */
    private final static String DEFAULT_ROOT = "dubbo";

    /**
     * Redis Key 過期機制執行器
     */
    private final ScheduledExecutorService expireExecutor = Executors.newScheduledThreadPool(1, new NamedThreadFactory("DubboRegistryExpireTimer", true));
    /**
     * Redis Key 過期機制 Future
     */
    private final ScheduledFuture<?> expireFuture;

    /**
     * Redis 根節點
     */
    private final String root;

    /**
     * JedisPool 集合
     *
     * key:ip:port
     */
    private final Map<String, JedisPool> jedisPools = new ConcurrentHashMap<String, JedisPool>();

    /**
     * 通知器集合
     *
     * key:Root + Service ,例如 `/dubbo/com.alibaba.dubbo.demo.DemoService`
     */
    private final ConcurrentMap<String, Notifier> notifiers = new ConcurrentHashMap<String, Notifier>();

    /**
     * 重連週期,單位:毫秒
     */
    private final int reconnectPeriod;
    /**
     * 過期週期,單位:毫秒
     */
    private final int expirePeriod;

    /**
     * 是否監控中心
     *
     * 用於判斷髒數據,髒數據由監控中心刪除 {@link #clean(Jedis)}
     */
    private volatile boolean admin = false;

    /**
     * 是否複製模式
     */
    private boolean replicate;

    public RedisRegistry(URL url) {
        super(url);
        if (url.isAnyHost()) {
            throw new IllegalStateException("registry address == null");
        }
        // 創建 GenericObjectPoolConfig 對象
        GenericObjectPoolConfig config = new GenericObjectPoolConfig();
        config.setTestOnBorrow(url.getParameter("test.on.borrow", true));
        config.setTestOnReturn(url.getParameter("test.on.return", false));
        config.setTestWhileIdle(url.getParameter("test.while.idle", false));
        if (url.getParameter("max.idle", 0) > 0)
            config.setMaxIdle(url.getParameter("max.idle", 0));
        if (url.getParameter("min.idle", 0) > 0)
            config.setMinIdle(url.getParameter("min.idle", 0));
        if (url.getParameter("max.active", 0) > 0)
            config.setMaxTotal(url.getParameter("max.active", 0));
        if (url.getParameter("max.total", 0) > 0)
            config.setMaxTotal(url.getParameter("max.total", 0));
        if (url.getParameter("max.wait", url.getParameter("timeout", 0)) > 0)
            config.setMaxWaitMillis(url.getParameter("max.wait", url.getParameter("timeout", 0)));
        if (url.getParameter("num.tests.per.eviction.run", 0) > 0)
            config.setNumTestsPerEvictionRun(url.getParameter("num.tests.per.eviction.run", 0));
        if (url.getParameter("time.between.eviction.runs.millis", 0) > 0)
            config.setTimeBetweenEvictionRunsMillis(url.getParameter("time.between.eviction.runs.millis", 0));
        if (url.getParameter("min.evictable.idle.time.millis", 0) > 0)
            config.setMinEvictableIdleTimeMillis(url.getParameter("min.evictable.idle.time.millis", 0));

        // 是否複製模式
        String cluster = url.getParameter("cluster", "failover");
        if (!"failover".equals(cluster) && !"replicate".equals(cluster)) {
            throw new IllegalArgumentException("Unsupported redis cluster: " + cluster + ". The redis cluster only supported failover or replicate.");
        }
        replicate = "replicate".equals(cluster);

        // 解析
        List<String> addresses = new ArrayList<String>();
        addresses.add(url.getAddress());
        String[] backups = url.getParameter(Constants.BACKUP_KEY, new String[0]);
        if (backups != null && backups.length > 0) {
            addresses.addAll(Arrays.asList(backups));
        }

        // 創建 JedisPool 對象
        String password = url.getPassword();
        for (String address : addresses) {
            int i = address.indexOf(':');
            String host;
            int port;
            if (i > 0) {
                host = address.substring(0, i);
                port = Integer.parseInt(address.substring(i + 1));
            } else {
                host = address;
                port = DEFAULT_REDIS_PORT;
            }
            if (StringUtils.isEmpty(password)) { // 無密碼連接
                this.jedisPools.put(address, new JedisPool(config, host, port,
                        url.getParameter(Constants.TIMEOUT_KEY, Constants.DEFAULT_TIMEOUT)));
            } else { // 有密碼連接
                this.jedisPools.put(address, new JedisPool(config, host, port,
                        url.getParameter(Constants.TIMEOUT_KEY, Constants.DEFAULT_TIMEOUT), password));
            }
        }

        // 解析重連週期
        this.reconnectPeriod = url.getParameter(Constants.REGISTRY_RECONNECT_PERIOD_KEY, Constants.DEFAULT_REGISTRY_RECONNECT_PERIOD);

        // 獲得 Redis 根節點
        String group = url.getParameter(Constants.GROUP_KEY, DEFAULT_ROOT);
        if (!group.startsWith(Constants.PATH_SEPARATOR)) { // 頭 `/`
            group = Constants.PATH_SEPARATOR + group;
        }
        if (!group.endsWith(Constants.PATH_SEPARATOR)) { // 尾 `/`
            group = group + Constants.PATH_SEPARATOR;
        }
        this.root = group;

        // 創建實現 Redis Key 過期機制的任務
        this.expirePeriod = url.getParameter(Constants.SESSION_TIMEOUT_KEY, Constants.DEFAULT_SESSION_TIMEOUT);
        this.expireFuture = expireExecutor.scheduleWithFixedDelay(new Runnable() {
            public void run() {
                try {
                    deferExpired(); // Extend the expiration time
                } catch (Throwable t) { // Defensive fault tolerance
                    logger.error("Unexpected exception occur at defer expire time, cause: " + t.getMessage(), t);
                }
            }
        }, expirePeriod / 2, expirePeriod / 2, TimeUnit.MILLISECONDS);
    }
2.2 doRegister

註冊信息

    public void doRegister(URL url) {
        String key = toCategoryPath(url);
        String value = url.toFullString();
        // 計算過期時間
        String expire = String.valueOf(System.currentTimeMillis() + expirePeriod);
        boolean success = false;
        RpcException exception = null;
        // 向 Redis 註冊
        for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
            JedisPool jedisPool = entry.getValue();
            try {
                Jedis jedis = jedisPool.getResource();
                try {
                    // 寫入 Redis Map 鍵
                    jedis.hset(key, value, expire);
                    // 發佈 Redis 註冊事件
                    jedis.publish(key, Constants.REGISTER);
                    success = true;
                    //  如果服務器端已同步數據,只需寫入單臺機器
                    if (!replicate) {
                        break; //  If the server side has synchronized data, just write a single machine
                    }
                } finally {
                    jedisPool.returnResource(jedis);
                }
            } catch (Throwable t) {
                exception = new RpcException("Failed to register service to redis registry. registry: " + entry.getKey() + ", service: " + url + ", cause: " + t.getMessage(), t);
            }
        }
        // 處理異常
        if (exception != null) {
            if (success) { // 雖然發生異常,但是結果成功
                logger.warn(exception.getMessage(), exception);
            } else { // 最終未成功
                throw exception;
            }
        }
    }

注意一下這裏的處理異常的寫法

2.2.1 toCategoryPath
    /**
     * 獲得分類路徑
     *
     * Root + Service + Type
     *
     * @param url URL
     * @return 分類路徑
     */
    private String toCategoryPath(URL url) {
        return toServicePath(url) + Constants.PATH_SEPARATOR + url.getParameter(Constants.CATEGORY_KEY, Constants.DEFAULT_CATEGORY);
    }
2.3 doUnregister
   public void doUnregister(URL url) {
        String key = toCategoryPath(url);
        String value = url.toFullString();
        RpcException exception = null;
        boolean success = false;
        // 向 Redis 註冊
        for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
            JedisPool jedisPool = entry.getValue();
            try {
                Jedis jedis = jedisPool.getResource();
                try {
                    // 刪除 Redis Map 鍵
                    jedis.hdel(key, value);
                    // 發佈 Redis 取消註冊事件
                    jedis.publish(key, Constants.UNREGISTER);
                    success = true;
                    //  如果服務器端已同步數據,只需寫入單臺機器
                    if (!replicate) {
                        break; //  If the server side has synchronized data, just write a single machine
                    }
                } finally {
                    jedisPool.returnResource(jedis);
                }
            } catch (Throwable t) {
                exception = new RpcException("Failed to unregister service to redis registry. registry: " + entry.getKey() + ", service: " + url + ", cause: " + t.getMessage(), t);
            }
        }
        // 處理異常
        if (exception != null) {
            if (success) { // 雖然發生異常,但是結果成功
                logger.warn(exception.getMessage(), exception);
            } else { // 最終未成功
                throw exception;
            }
        }
    }
2.4 doSubscribe
 public void doSubscribe(final URL url, final NotifyListener listener) {
        // 獲得服務路徑,例如:`/dubbo/com.alibaba.dubbo.demo.DemoService`
        String service = toServicePath(url);
        // 獲得通知器 Notifier 對象
        Notifier notifier = notifiers.get(service);
        // 不存在,則創建 Notifier 對象
        if (notifier == null) {
            Notifier newNotifier = new Notifier(service);
            notifiers.putIfAbsent(service, newNotifier);
            notifier = notifiers.get(service);
            if (notifier == newNotifier) { // 保證併發的情況下,有且僅有一個啓動
                notifier.start();
            }
        }
        boolean success = false;
        RpcException exception = null;
        // 循環 `jedisPools` ,僅向一個 Redis 發起訂閱
        for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
            JedisPool jedisPool = entry.getValue();
            try {
                Jedis jedis = jedisPool.getResource();
                try {
                    // 處理所有 Service 層的發起訂閱,例如監控中心的訂閱
                    if (service.endsWith(Constants.ANY_VALUE)) {
                        admin = true;
                        // 獲得分類層集合,例如:`/dubbo/com.alibaba.dubbo.demo.DemoService/providers`
                        Set<String> keys = jedis.keys(service);
                        if (keys != null && !keys.isEmpty()) {
                            // 按照服務聚合 URL 集合
                            Map<String, Set<String>> serviceKeys = new HashMap<String, Set<String>>(); // Key:Root + Service ; Value:URL 。
                            for (String key : keys) {
                                String serviceKey = toServicePath(key);
                                Set<String> sk = serviceKeys.get(serviceKey);
                                if (sk == null) {
                                    sk = new HashSet<String>();
                                    serviceKeys.put(serviceKey, sk);
                                }
                                sk.add(key);
                            }
                            // 循環 serviceKeys ,按照每個 Service 層的發起通知
                            for (Set<String> sk : serviceKeys.values()) {
                                doNotify(jedis, sk, url, Collections.singletonList(listener));
                            }
                        }
                    // 處理指定 Service 層的發起通知
                    } else {
                        doNotify(jedis, jedis.keys(service + Constants.PATH_SEPARATOR + Constants.ANY_VALUE), url, Collections.singletonList(listener));
                    }
                    // 標記成功
                    success = true;
                    // 結束,僅僅從一臺服務器讀取數據
                    break; // Just read one server's data
                } finally {
                    jedisPool.returnResource(jedis);
                }
            } catch (Throwable t) { // Try the next server
                exception = new RpcException("Failed to subscribe service from redis registry. registry: " + entry.getKey() + ", service: " + url + ", cause: " + t.getMessage(), t);
            }
        }
        // 處理異常
        if (exception != null) {
            if (success) { // 雖然發生異常,但是結果成功
                logger.warn(exception.getMessage(), exception);
            } else { // 最終未成功
                throw exception;
            }
        }
    }
2.4.1 toServicePath

獲得服務路徑

    /**
     * 獲得服務路徑,主要截掉多餘的部分
     *
     * Root + Type
     *
     * @param categoryPath 分類路徑
     * @return 服務路徑
     */
    private String toServicePath(String categoryPath) {
        int i;
        if (categoryPath.startsWith(root)) {
            i = categoryPath.indexOf(Constants.PATH_SEPARATOR, root.length());
        } else {
            i = categoryPath.indexOf(Constants.PATH_SEPARATOR);
        }
        return i > 0 ? categoryPath.substring(0, i) : categoryPath;
    }
2.4.2 toServicePath

獲得服務路徑


    /**
     * 獲得服務路徑
     *
     * Root + Type
     *
     * @param url URL
     * @return 服務路徑
     */
    private String toServicePath(URL url) {
        return root + url.getServiceInterface();
    }
2.5 doNotify
   // @params key 分類數組,例如:`/dubbo/com.alibaba.dubbo.demo.DemoService/providers`
    private void doNotify(Jedis jedis, String key) {
        for (Map.Entry<URL, Set<NotifyListener>> entry : new HashMap<URL, Set<NotifyListener>>(getSubscribed()).entrySet()) {
            doNotify(jedis, Collections.singletonList(key), entry.getKey(), new HashSet<NotifyListener>(entry.getValue()));
        }
    }

    // @params keys 分類數組,元素例如:`/dubbo/com.alibaba.dubbo.demo.DemoService/providers`
    private void doNotify(Jedis jedis, Collection<String> keys, URL url, Collection<NotifyListener> listeners) {
        if (keys == null || keys.isEmpty() || listeners == null || listeners.isEmpty()) {
            return;
        }
        long now = System.currentTimeMillis();
        List<URL> result = new ArrayList<URL>();
        List<String> categories = Arrays.asList(url.getParameter(Constants.CATEGORY_KEY, new String[0])); // 分類數組
        String consumerService = url.getServiceInterface(); // 服務接口
        // 循環分類層,例如:`/dubbo/com.alibaba.dubbo.demo.DemoService/providers`
        for (String key : keys) {
            // 若服務不匹配,返回
            if (!Constants.ANY_VALUE.equals(consumerService)) {
                String providerService = toServiceName(key);
                if (!providerService.equals(consumerService)) {
                    continue;
                }
            }
            // 若訂閱的不包含該分類,返回
            String category = toCategoryName(key);
            if (!categories.contains(Constants.ANY_VALUE) && !categories.contains(category)) {
                continue;
            }
            // 獲得所有 URL 數組
            List<URL> urls = new ArrayList<URL>();
            Map<String, String> values = jedis.hgetAll(key);
            if (values != null && values.size() > 0) {
                for (Map.Entry<String, String> entry : values.entrySet()) {
                    URL u = URL.valueOf(entry.getKey());
                    if (!u.getParameter(Constants.DYNAMIC_KEY, true) // 非動態節點,因爲動態節點,不受過期的限制
                            || Long.parseLong(entry.getValue()) >= now) { // 未過期
                        if (UrlUtils.isMatch(url, u)) {
                            urls.add(u);
                        }
                    }
                }
            }
            // 若不存在匹配,則創建 `empty://` 的 URL返回,用於清空該服務的該分類。
            if (urls.isEmpty()) {
                urls.add(url.setProtocol(Constants.EMPTY_PROTOCOL)
                        .setAddress(Constants.ANYHOST_VALUE)
                        .setPath(toServiceName(key))
                        .addParameter(Constants.CATEGORY_KEY, category));
            }
            result.addAll(urls);
            if (logger.isInfoEnabled()) {
                logger.info("redis notify: " + key + " = " + urls);
            }
        }
        if (result.isEmpty()) {
            return;
        }
        // 全量數據獲取完成時,調用 `super#notify(...)` 方法,回調 NotifyListener
        for (NotifyListener listener : listeners) {
            super.notify(url, listener, result);
        }
    }
2.5.1 toServiceName

    /**
     * 獲得服務名,從服務路徑上
     *
     * Service
     *
     * @param categoryPath 服務路徑
     * @return 服務名
     */
    private String toServiceName(String categoryPath) {
        String servicePath = toServicePath(categoryPath);
        return servicePath.startsWith(root) ? servicePath.substring(root.length()) : servicePath;
    }
2.5.2 toServiceName
   /**
     * 獲得分類名,從分類路徑上
     *
     * Type
     *
     * @param categoryPath 分類路徑
     * @return 分類名
     */
    private String toCategoryName(String categoryPath) {
        int i = categoryPath.lastIndexOf(Constants.PATH_SEPARATOR);
        return i > 0 ? categoryPath.substring(i + 1) : categoryPath;
    }

2.6 deferExpired

 private void deferExpired() {
        for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
            JedisPool jedisPool = entry.getValue();
            try {
                Jedis jedis = jedisPool.getResource();
                try {
                    // 循環已註冊的 URL 集合
                    for (URL url : new HashSet<URL>(getRegistered())) {
                        // 動態節點
                        if (url.getParameter(Constants.DYNAMIC_KEY, true)) {
                            // 獲得分類路徑
                            String key = toCategoryPath(url);
                            // 寫入 Redis Map 中
                            if (jedis.hset(key, url.toFullString(), String.valueOf(System.currentTimeMillis() + expirePeriod)) == 1) {
                                // 發佈 `register` 事件。
                                jedis.publish(key, Constants.REGISTER);
                            }
                        }
                    }
                    // 監控中心負責刪除過期髒數據
                    if (admin) {
                        clean(jedis);
                    }
                    // 如果服務器端已同步數據,只需寫入單臺機器
                    if (!replicate) {
                        break;//  If the server side has synchronized data, just write a single machine
                    }
                } finally {
                    jedisPool.returnResource(jedis);
                }
            } catch (Throwable t) {
                logger.warn("Failed to write provider heartbeat to redis registry. registry: " + entry.getKey() + ", cause: " + t.getMessage(), t);
            }
        }
    }
2.6.1 clean

監控中心負責刪除過期髒數據

    private void clean(Jedis jedis) {
        // 獲得所有服務
        Set<String> keys = jedis.keys(root + Constants.ANY_VALUE);
        if (keys != null && !keys.isEmpty()) {
            for (String key : keys) {
                // 獲得所有 URL
                Map<String, String> values = jedis.hgetAll(key);
                if (values != null && values.size() > 0) {
                    boolean delete = false;
                    long now = System.currentTimeMillis();
                    for (Map.Entry<String, String> entry : values.entrySet()) {
                        URL url = URL.valueOf(entry.getKey());
                        // 動態節點
                        if (url.getParameter(Constants.DYNAMIC_KEY, true)) {
                            long expire = Long.parseLong(entry.getValue());
                            // 已經過期
                            if (expire < now) {
                                //
                                jedis.hdel(key, entry.getKey());
                                delete = true;
                                if (logger.isWarnEnabled()) {
                                    logger.warn("Delete expired key: " + key + " -> value: " + entry.getKey() + ", expire: " + new Date(expire) + ", now: " + new Date(now));
                                }
                            }
                        }
                    }
                    // 若刪除成功,發佈 `unregister` 事件
                    if (delete) {
                        jedis.publish(key, Constants.UNREGISTER);
                    }
                }
            }
        }
    }

2.6 isAvailable

判斷節點是否可用

    public boolean isAvailable() {
        for (JedisPool jedisPool : jedisPools.values()) {
            try {
                Jedis jedis = jedisPool.getResource();
                try {
                    if (jedis.isConnected()) { // 至少一個 Redis 節點可用
                        return true; // At least one single machine is available.
                    }
                } finally {
                    jedisPool.returnResource(jedis);
                }
            } catch (Throwable ignored) {
            }
        }
        return false;
    }

2.7 destroy

    @Override
    public void destroy() {
        // 父類關閉
        super.destroy();
        // 關閉定時任務
        try {
            expireFuture.cancel(true);
        } catch (Throwable t) {
            logger.warn(t.getMessage(), t);
        }
        // 關閉通知器
        try {
            for (Notifier notifier : notifiers.values()) {
                notifier.shutdown();
            }
        } catch (Throwable t) {
            logger.warn(t.getMessage(), t);
        }
        // 關閉連接池
        for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
            JedisPool jedisPool = entry.getValue();
            try {
                jedisPool.destroy();
            } catch (Throwable t) {
                logger.warn("Failed to destroy the redis registry client. registry: " + entry.getKey() + ", cause: " + t.getMessage(), t);
            }
        }
    }

3 Notifier

3.1 構造方法和屬性
 /**
         * 服務名 Root + Service
         */
        private final String service;
        /**
         * Jedis
         */
        private volatile Jedis jedis;
        /**
         * 是否首次
         */
        private volatile boolean first = true;
        /**
         * 是否運行中
         */
        private volatile boolean running = true;
        /**
         * 連接次數隨機數
         */
        private volatile int connectRandom;
        /**
         * 需要忽略連接的次數
         */
        private final AtomicInteger connectSkip = new AtomicInteger();
        /**
         * 已經忽略連接的次數
         */
        private final AtomicInteger connectSkiped = new AtomicInteger();
        /**
         * 隨機
         */
        private final Random random = new Random();

        public Notifier(String service) {
            super.setDaemon(true);
            super.setName("DubboRedisSubscribe");
            this.service = service;
        }
3.2 run
 public void run() {
            while (running) {
                try {
                    // 是否跳過本次 Redis 連接
                    if (!isSkip()) {
                        try {
                            for (Map.Entry<String, JedisPool> entry : jedisPools.entrySet()) {
                                JedisPool jedisPool = entry.getValue();
                                try {
                                    jedis = jedisPool.getResource();
                                    try {
                                        // 監控中心
                                        if (service.endsWith(Constants.ANY_VALUE)) {
                                            if (!first) {
                                                first = false;
                                                Set<String> keys = jedis.keys(service);
                                                if (keys != null && !keys.isEmpty()) {
                                                    for (String s : keys) {
                                                        doNotify(jedis, s);
                                                    }
                                                }
                                                resetSkip();
                                            }
                                            // 批訂閱
                                            jedis.psubscribe(new NotifySub(jedisPool), service); // blocking
                                        // 服務提供者或消費者
                                        } else {
                                            if (!first) {
                                                first = false;
                                                doNotify(jedis, service);
                                                resetSkip();
                                            }
                                            // 批訂閱
                                            jedis.psubscribe(new NotifySub(jedisPool), service + Constants.PATH_SEPARATOR + Constants.ANY_VALUE); // blocking
                                        }
                                        break;
                                    } finally {
                                        jedisPool.returnBrokenResource(jedis);
                                    }
                                } catch (Throwable t) { // Retry another server
                                    logger.warn("Failed to subscribe service from redis registry. registry: " + entry.getKey() + ", cause: " + t.getMessage(), t);
                                    // If you only have a single redis, you need to take a rest to avoid overtaking a lot of CPU resources
                                    sleep(reconnectPeriod);
                                }
                            }
                        } catch (Throwable t) {
                            logger.error(t.getMessage(), t);
                            sleep(reconnectPeriod);
                        }
                    }
                } catch (Throwable t) {
                    logger.error(t.getMessage(), t);
                }
            }
        }

4 NotifySub

 /**
     * 通知訂閱實現類
     */
    private class NotifySub extends JedisPubSub {

        private final JedisPool jedisPool;

        public NotifySub(JedisPool jedisPool) {
            this.jedisPool = jedisPool;
        }

        @Override
        public void onMessage(String key, String msg) {
            if (logger.isInfoEnabled()) {
                logger.info("redis event: " + key + " = " + msg);
            }
            if (msg.equals(Constants.REGISTER)
                    || msg.equals(Constants.UNREGISTER)) {
                try {
                    Jedis jedis = jedisPool.getResource();
                    try {
                        doNotify(jedis, key);
                    } finally {
                        jedisPool.returnResource(jedis);
                    }
                } catch (Throwable t) { // TODO Notification failure does not restore mechanism guarantee
                    logger.error(t.getMessage(), t);
                }
            }
        }

        @Override
        public void onPMessage(String pattern, String key, String msg) {
            onMessage(key, msg);
        }

        @Override
        public void onSubscribe(String key, int num) {
        }

        @Override
        public void onPSubscribe(String pattern, int num) {
        }

        @Override
        public void onUnsubscribe(String key, int num) {
        }

        @Override
        public void onPUnsubscribe(String pattern, int num) {
        }

    }
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章