分佈式任務定時框架elasticjob詳解

 

目錄

序言

一 基本概念

二 整體架構圖

三 Elastic-Job的具體模塊的底層及如何實現

四 作業開發

五 引入Maven依賴

六 作業配置

七 與Spring結合

Spring部分配置參數說明

 註冊中心配置(只支持zookepper)

 作業配置

 分片策略

八 動態添加job

springboot項目

1 依賴

2  yaml配置

3 elastic-job相關的配置改成java代碼實現

4 elastic-job-lite-console 動態管理

九 對比

與Spring Batch比較

參考


序言

如果你需要管理job,可以考慮使用elasticjob。

如果你想動態添加job ,也可以考慮使用elasticjob。

如果對系統可靠性,穩定性和服務器彈性等要求比較高,還可以考慮elasticjob。

Elastic-Job是噹噹網基於Zookepper,Quartz開發並且開源的Java分佈式定時任務,解決Quartz不支持分佈式的弊端。它由兩個相

互獨立的子項目Elastic-Job-Lite和Elastic-Job-Cloud組成。

一 基本概念

  1. 分片概念:任務分佈式的執行,需要將一個任務拆分成多個獨立的任務項,然後由分佈式的服務器分別執行某一個或幾個分片項。
  2. 個性化參數:shardingItemParameter,可以和分片項匹配對應關係。比如:將商品的狀態分成上架,下架。那麼配置0=上架,1=下架,代碼中直接使用上架下架的枚舉值即可完成分片項與業務邏輯的對應關係。
  3. 作用高可用:將分片總數設置成1,多臺服務器執行作業將採用1主n從的方式執行。
  4. 彈性擴容:將任務拆分爲n個任務項後,各個服務器分別執行各自分配到的任務項。一旦有新的服器加入集羣或有服務器宕機。Elastic-Job將保留本次任務不變,下次任務開始前重新分片。
  5. 並行調度:採用任務分片方式實現。將一個任務拆分爲n個獨立的任務項,由分佈式的服務器並行執行各自分配到的分片項。
  6. 集中管理:採用基於zookepper的註冊中心,集中管理和協調分佈式作業的狀態,分配和監聽。外部系統可直接根據Zookeeper的數據管理和監控elastic-job。
  7. 定製化流程任務:作業可分爲簡單和數據流處理兩種模式,數據流又分爲高吞吐處理模式和順序性處理模式,其中高吞吐處理模式可以開啓足夠多的線程快速的處理數據,而順序性處理模式將每個分片項分配到一個獨立線程,用於保證同一分片的順序性,這點類似於kafka的分區順序性。

二 整體架構圖

三 Elastic-Job的具體模塊的底層及如何實現

Elastic-Job採用去中心化設計,主要分爲註冊中心、數據分片、分佈式協調、定時任務處理和定製化流程型任務等模塊。

  1. 去中心化:指Elastic-Job沒有調度中心這一概念。每個運行在集羣中的作業服務器都是對等的,節點之間通過註冊中心進行分佈式協調。但elastic-job有主節點的概念,主節點用於處理一些集中式任務,如分片,清理運行時信息等,並無調度功能,定時調度都是由作業服務器自行觸發。
      中心化 去中心化
    實現難度
    部署難度
    觸發時間統一控制 可以  不可以
    觸發延遲
    異構語言支持 容易 困難
  2. 註冊中心:註冊中心模塊目前直接使用zookeeper,用於記錄作業的配置,服務器信息以及作業運行狀態。Zookeeper雖然很成熟,但原理複雜,使用較難,在海量數據支持的情況下也會有性能和網絡問題。
  3. 數據分片:數據分片是elastic-job中實現分佈式的重要概念,將真實數據和邏輯分片對應,用於解耦作業框架和數據的關係。作業框架只負責將分片合理的分配給相關的作業服務器,而作業服務器需要根據所分配的分片匹配數據進行處理。服務器分片目前都存儲在註冊中心中,各個服務器根據自己的IP地址拉取分片。
  4. 分佈式協調:分佈式協調模塊用於處理作業服務器的動態擴容縮容。一旦集羣中有服務器發生變化,分佈式協調將自動監測並將變化結果通知仍存活的作業服務器。協調時將會涉及主節點選舉,重分片等操作。目前使用的Zookeeper的臨時節點和監聽器實現主動檢查和通知功能。
  5. 定時任務處理:定時任務處理根據cron表達式定時觸發任務,目前有防止任務同時觸發,錯過任務重出發等功能。主要還是使用Quartz本身的定時調度功能,爲了便於控制,每個任務都使用獨立的線程池。
  6. 定製化流程型任務:定製化流程型任務將定時任務分爲多種流程,有不經任何修飾的簡單任務;有用於處理數據的fetchData/processData的數據流任務;以後還將增加消息流任務,文件任務,工作流任務等。用戶能以插件的形式擴展並貢獻代碼。

四 作業開發

Elastic-Job提供Simple、Dataflow和Script 3種作業類型。方法參數shardingContext包含作業配置、片和運行時信息。可通過getShardingTotalCount(), getShardingItem()等方法分別獲取分片總數,運行在本作業服務器的分片序列號等。

  1. Simple類型的作業:該類型意爲簡單實現,只需實現SimpleJob接口,重寫它的execute方法即可
  2. Dataflow類型作業:用於處理數據流,實現DataflowJob接口,並重寫兩個方法——用於抓取(fetchData方法)和處理(processData方法)數據。比如在fetchData方法裏面查詢沒有上架的商品,在processData方法修改該商品的狀態。注意:可通過DataflowJobConfiguration配置是否流式處理。當配置成流式處理,fetchData方法返回值(返回值是集合)是null或長度是0,作業才停止抓取,否則將一直運行。非流式的則每次作業只執行一次這兩個方法就結束該作業。
  3. 注意:可通過DataflowJobConfiguration配置是否流式處理。當配置成流式處理,fetchData方法返回值(返回值是集合)是null或長度是0,作業才停止抓取,否則將一直運行。非流式的則每次作業只執行一次這兩個方法就結束該作業。
  4. Script類型作業:意爲腳本類型作業,支持shell、python、perl等類型腳本。只需通過控制檯或代碼配置scriptCommandLine即可,無需編碼。

五 引入Maven依賴

<!-- 引入elastic-job-lite核心模塊 -->
<dependency>
    <groupId>io.elasticjob</groupId>
    <artifactId>elastic-job-lite-core</artifactId>
    <version>${latest.release.version}</version>
</dependency>

<!-- 使用springframework自定義命名空間時引入 -->
<dependency>
    <groupId>io.elasticjob</groupId>
    <artifactId>elastic-job-lite-spring</artifactId>
    <version>${latest.release.version}</version>
</dependency>

六 作業配置

Elasti-Job配置分成3個層級,Core, Type和Root。

  1. Core對應JobCoreConfiguration,用於提供作業核心配置信息,如:作業名稱、分片總數、CRON表達式等。
  2. Type對應JobTypeConfiguration,有三個子類分別對應SIMPLE, DATAFLOW和SCRIPT類型作業,供3種作業需要的不同配置,如:DATAFLOW類型是否流式處理或SCRIPT類型的命令行等。
  3. Root對應JobRootConfiguration,有兩個子類分別對應Lite和Cloud部署類型,提供不同部署類型所需的配置,如:Lite類型的是否需要覆蓋本地配置或Cloud佔用CPU或Memory數量等。

七 與Spring結合

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:reg="http://www.dangdang.com/schema/ddframe/reg"
    xmlns:job="http://www.dangdang.com/schema/ddframe/job"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
                        http://www.springframework.org/schema/beans/spring-beans.xsd 
                        http://www.dangdang.com/schema/ddframe/reg 
                        http://www.dangdang.com/schema/ddframe/reg/reg.xsd 
                        http://www.dangdang.com/schema/ddframe/job 
                        http://www.dangdang.com/schema/ddframe/job/job.xsd 
                        ">
    <!--配置作業註冊中心-->
    <reg:zookeeper id="regCenter" server-lists="127.0.0.1:2181" namespace="dd-job" base-sleep-time-milliseconds="1000" max-sleep-time-milliseconds="3000" max-retries="3" />
    
    <!-- 配置簡單作業-->
    <job:simple id="simpleElasticJob" class="xxx.MySimpleElasticJob" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C" />
    
    <bean id="yourRefJobBeanId" class="xxx.MySimpleRefElasticJob">
        <property name="fooService" ref="xxx.FooService"/>
    </bean>
    
    <!-- 配置關聯Bean作業-->
    <job:simple id="simpleRefElasticJob" job-ref="yourRefJobBeanId" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C" />
    
    <!-- 配置數據流作業-->
    <job:dataflow id="throughputDataflow" class="xxx.MyThroughputDataflowElasticJob" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C" />
    
    <!-- 配置腳本作業-->
    <job:script id="scriptElasticJob" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C" script-command-line="/your/file/path/demo.sh" />
    
    <!-- 配置帶監聽的簡單作業-->
    <job:simple id="listenerElasticJob" class="xxx.MySimpleListenerElasticJob" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C">
        <job:listener class="xx.MySimpleJobListener"/>
        <job:distributed-listener class="xx.MyOnceSimpleJobListener" started-timeout-milliseconds="1000" completed-timeout-milliseconds="2000" />
    </job:simple>
    
    <!-- 配置帶作業數據庫事件追蹤的簡單作業-->
    <job:simple id="eventTraceElasticJob" class="xxx.MySimpleListenerElasticJob" registry-center-ref="regCenter" cron="0/10 * * * * ?" sharding-total-count="3" sharding-item-parameters="0=A,1=B,2=C" event-trace-rdb-data-source="yourDataSource">
    </job:simple>
</beans>

補充:啓動zookepper,通過spring啓動配置,作業就能加載。

Spring部分配置參數說明

全部配置參考配置手冊

 註冊中心配置(只支持zookepper)

  • id:註冊中心在Spring容器中的主鍵
  • server-lists:IP地址加端口號,可配置多個,用逗號隔開
  • namespace:zookepper的命名空間
  • max-retries:最大重置次數

 作業配置

JobCoreConfiguration屬性

  • id:作業名稱
  • class:作業實現類,需實現ElasticJob接口
  • cron:cron表達式,控制作用觸發時間
  • sharding-total-count:作業分片總數
  • registry-center-ref:註冊中心bean的引用
  • sharding-item-parameters:分片序列號和參數用等號分隔,多個鍵值對用逗號分隔片,序列號從0開始,不可大於或等於作業分片總數如:0=a,1=b,2=c
  • failover:是否開啓失效轉移
    補充:開啓失效轉移的情況下,如果任務執行過程中一臺服務器失去連接,那麼已經分配到該服務器的任務,將會在下次任務執行之前被當前集羣中正常的服務器獲取分片並執行,執行結束後再進行下一次任務;未開啓失效轉移,那麼服務器丟失後,程序將不作任務處理,任由其丟失,但下次任務會重新分片。
  • disabled:作業是否禁止啓動
  • overwrite:本地配置是否可覆蓋註冊中心配置,如果可覆蓋,每次啓動作業都以本地配置爲準
  • event-trace-rdb-data-source:作業事件追蹤的數據源Bean引用
  • streaming-process:dataflow特有的——是否流式處理數據
  • job-sharding-strategy-class:作業分片策略實現類全路徑

 分片策略

  1. AverageAllocationJobShardingStrategy:平均分配,默認分配策略,不能整除的多餘分片將依次追加到序號小的服務器
  2. OdevitySortByNameJobShardingStrategy:根據作業名的哈希值奇偶數決定IP升降序算法的分片策略。作業名的哈希值爲奇數則IP升序,偶數則IP降序
  3. RotateServerByNameJobShardingStrategy:根據作業名的哈希值對服務器列表進行輪轉的分片策略
  4. 自定義策略:實現JobShardingStrategy接口並實現sharding方法,接口方法參數爲作業服務器IP列表和分片策略選項,分片策略選項包括作業名稱,分片總數以及分片序列號和個性化參數對照表

八 動態添加job

springboot項目

創建一個簡單的springboot項目,maven依賴和上邊差不多,使用yaml進行相關屬性的配置,主要配置的是數據庫連接池,jpa

1 依賴

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>com.dangdang</groupId>
            <artifactId>elastic-job-lite-spring</artifactId>
            <version>2.1.5</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
        </dependency>
    </dependencies>


2  yaml配置

elasticjob:
     serverlists: 172.31.31.48:2181
     namespace: boot-job

   spring:
     datasource:
       url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&verifyServerCertificate=false&useSSL=false&requireSSL=false
       driver-class-name: com.mysql.jdbc.Driver
       username: root
       password: root
       type: com.zaxxer.hikari.HikariDataSource
   #  自動創建更新驗證數據庫結構
     jpa:
       hibernate:
         ddl-auto: update
         show-sql: true
         database: mysql

3 elastic-job相關的配置改成java代碼實現

使用java配置實現,代替官方文檔的xml配置

@Configuration
@Data
@ConfigurationProperties(prefix = "elasticjob")
public class ElasticJobConfig {
    private String serverlists;
    private String namespace;
    @Resource
    private HikariDataSource dataSource;

    @Bean
    public ZookeeperConfiguration zkConfig() {
        return new ZookeeperConfiguration(serverlists, namespace);
    }

    @Bean(initMethod = "init")
    public ZookeeperRegistryCenter regCenter(ZookeeperConfiguration config) {
        return new ZookeeperRegistryCenter(config);
    }

    /**
     * 將作業運行的痕跡進行持久化到DB
     *
     * @return
     */
    @Bean
    public JobEventConfiguration jobEventConfiguration() {
        return new JobEventRdbConfiguration(dataSource);
    }

    @Bean
    public ElasticJobListener elasticJobListener() {
        return new ElasticJobListener(100, 100);
    }
}
所有相關的配置到這裏就已經OK了,接下來開始具體的編碼實現。

定時任務實現
先實現一個自己的任務類,需要實現elastic-job提供的SimpleJob接口,實現它的execute(ShardingContext shardingContext)方法

@Slf4j
public class MyElasticJob implements SimpleJob {
    @Override
    public void execute(ShardingContext shardingContext) {
        //打印出任務相關信息,JobParameter用於傳遞任務的ID
        log.info("任務名:{}, 片數:{}, id={}", shardingContext.getJobName(), shardingContext.getShardingTotalCount(),
                shardingContext.getJobParameter());
    }
}

接下來實現一個分佈式的任務監聽器,如果任務有分片,分佈式監聽器會在總的任務開始前執行一次,結束時執行一次。監聽器在之前的ElasticJobConfig已經註冊到了Spring容器之中。

public class ElasticJobListener extends AbstractDistributeOnceElasticJobListener {
    @Resource
    private TaskRepository taskRepository;

    public ElasticJobListener(long startedTimeoutMilliseconds, long completedTimeoutMilliseconds) {
        super(startedTimeoutMilliseconds, completedTimeoutMilliseconds);
    }

    @Override
    public void doBeforeJobExecutedAtLastStarted(ShardingContexts shardingContexts) {
    }

    @Override
    public void doAfterJobExecutedAtLastCompleted(ShardingContexts shardingContexts) {
        //任務執行完成後更新狀態爲已執行
        JobTask jobTask = taskRepository.findOne(Long.valueOf(shardingContexts.getJobParameter()));
        jobTask.setStatus(1);
        taskRepository.save(jobTask);
    }
}

實現一個ElasticJobHandler,用於向Elastic-job中添加指定的作業配置,作業配置分爲3級,分別是JobCoreConfiguration,JobTypeConfiguration和LiteJobConfiguration。LiteJobConfiguration使用JobTypeConfiguration,JobTypeConfiguration使用JobCoreConfiguration,層層嵌套。

@Component
public class ElasticJobHandler {
    @Resource
    private ZookeeperRegistryCenter registryCenter;
    @Resource
    private JobEventConfiguration jobEventConfiguration;
    @Resource
    private ElasticJobListener elasticJobListener;

    /**
     * @param jobName
     * @param jobClass
     * @param shardingTotalCount
     * @param cron
     * @param id                 數據ID
     * @return
     */
    private static LiteJobConfiguration.Builder simpleJobConfigBuilder(String jobName,
                                                                       Class<? extends SimpleJob> jobClass,
                                                                       int shardingTotalCount,
                                                                       String cron,
                                                                       String id) {
        return LiteJobConfiguration.newBuilder(new SimpleJobConfiguration(
                JobCoreConfiguration.newBuilder(jobName, cron, shardingTotalCount).jobParameter(id).build(), jobClass.getCanonicalName()));
    }

    /**
     * 添加一個定時任務
     *
     * @param jobName            任務名
     * @param cron               表達式
     * @param shardingTotalCount 分片數
     */
    public void addJob(String jobName, String cron, Integer shardingTotalCount, String id) {
        LiteJobConfiguration jobConfig = simpleJobConfigBuilder(jobName, MyElasticJob.class, shardingTotalCount, cron, id)
                .overwrite(true).build();

        new SpringJobScheduler(new MyElasticJob(), registryCenter, jobConfig, jobEventConfiguration, elasticJobListener).init();
    }
}

到這裏,elastic-job的註冊中心,數據源相關配置,以及動態添加的邏輯已經做完了,接下來在service中調用上面寫好的方法,驗證功能是否正常。

編寫一個ElasticJobService類,掃描數據庫中狀態爲0的任務,並且把這些任務添加到Elastic-job中,這裏的相關數據庫操作使用了spring-data-jpa,dao層相關代碼就不貼了,可以在源碼中查看。

@Service
public class ElasticJobService {
    @Resource
    private ElasticJobHandler jobHandler;
    @Resource
    private TaskRepository taskRepository;

    /**
     * 掃描db,並添加任務
     */
    public void scanAddJob() {
        Specification query = (Specification<JobTask>) (root, criteriaQuery, criteriaBuilder) -> criteriaBuilder
                .and(criteriaBuilder.equal(root.get("status"), 0));
        List<JobTask> jobTasks = taskRepository.findAll(query);
        jobTasks.forEach(jobTask -> {
            Long current = System.currentTimeMillis();
            String jobName = "job" + jobTask.getSendTime();
            String cron;
            //說明消費未發送,但是已經過了消息的發送時間,調整時間繼續執行任務
            if (jobTask.getSendTime() < current) {
                //設置爲一分鐘之後執行,把Date轉換爲cron表達式
                cron = CronUtils.getCron(new Date(current + 60000));
            } else {
                cron = CronUtils.getCron(new Date(jobTask.getSendTime()));
            }
            jobHandler.addJob(jobName, cron, 1, String.valueOf(jobTask.getId()));
        });
    }
}

在Junit中添加幾條測試數據

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class JobTaskTest {
    @Resource
    private TaskRepository taskRepository;

    @Test
    public void add() {
        //生成幾個任務,第一任務在三分鐘之後
        Long unixTime = System.currentTimeMillis() + 60000;
        JobTask task = new JobTask("test-msg-1", 0, unixTime);
        taskRepository.save(task);
        unixTime += 60000;
        task = new JobTask("test-msg-2", 0, unixTime);
        taskRepository.save(task);
        unixTime += 60000;
        task = new JobTask("test-msg-3", 0, unixTime);
        taskRepository.save(task);
        unixTime += 60000;
        task = new JobTask("test-msg-4", 0, unixTime);
        taskRepository.save(task);
    }
}

此時,數據庫中多了四條狀態爲0的數據

最後,就可以開始驗證整個流程了,代碼如下

@SpringBootApplication
public class ElasticJobApplication implements CommandLineRunner {
    @Resource
    private ElasticJobService elasticJobService;

    public static void main(String[] args) {
        SpringApplication.run(ElasticJobApplication.class, args);
    }

    @Override
    public void run(String... strings) throws Exception {
        elasticJobService.scanAddJob();
    }
}

可以看到,在啓動過程中,多個任務被加入到了Elastic-job中,並且一小段時間之後,任務一次執行,執行成功之後,因爲我們配置了監聽器,會打印數據庫的更新SQL,當任務執行完成,再查看數據庫,發現狀態也更改成功。數據庫中同時也會多出兩張表JOB_EXECUTION_LOG,JOB_STATUS_TRACE_LOG,這是我們之前配置的JobEventConfiguration,通過數據源持久化了作業配置的相關數據,這兩張表的數據可以供Elastic-job提供的運維平臺使用,具體請查看官方文檔。

以上 主要實現了動態添加job 功能 還可以動態管理job,例如動態刪除,修改等。


4 elastic-job-lite-console 動態管理

1.下載或者克隆elastic-job源碼

地址:https://github.com/dangdangdotcom/elastic-job

2.maven編譯安裝

進入到elastic-job目錄,按住Shift+鼠標右鍵,選擇“在此處打開命令窗口(W)”,執行如下命令:

  1. mvn clean install -Dmaven.test.skip=true  

等待編譯安裝結束


3.解壓上一步打好的包

路徑:elastic-job\elastic-job-lite\elastic-job-lite-console\target\elastic-job-lite-console-2.1.5.tar.gz

elastic-job-lite-console-2.1.5\bin目錄下是啓動腳本

windows環境用:start.bat

linux環境用:start.sh

elastic-job-lite-console-2.1.5\conf目錄下是配置文件auth.properties,配置的用戶名和密碼

root.username=root
root.password=root
guest.username=guest
guest.password=guest

4.以windows環境爲例,雙擊start.bat啓動

打開啓動腳本看到啓動的端口:8899

@echo off
if ""%1"" == ""-p"" goto doSetPort
if ""%1"" == """" goto doStart

echo Usage:  %0 [OPTIONS]
echo   -p [port]          Server port (default: 8899)
goto end

:doSetPort
shift
set PORT=%1

:doStart
set CFG_DIR=%~dp0%..
set CLASSPATH=%CFG_DIR%
set CLASSPATH=%~dp0..\lib\*;%CLASSPATH%
set CONSOLE_MAIN=io.elasticjob.lite.console.ConsoleBootstrap
echo on
if ""%PORT%"" == """" set PORT=8899
java  -cp "%CLASSPATH%" %CONSOLE_MAIN% %PORT%

:end

5.訪問:localhost:8899 ,登錄用戶名:root,密碼:root

九 對比

與Spring Batch比較

  • Spring Batch 是一款批處理應用框架,不是調度框架。如果我們希望批處理任務定期執行,可結合 Quartz 等成熟的調度框架實現。Elastic-Job集成了調度框架,不需要額外添加
  • Spring Batch提供了豐富的讀寫組件,適用於複雜的流程化作業
  • Elastic-Job採用分片的方式,是分佈式調度解決方案。適用場景是:相對於流程比較簡單,但是任務可以拆分到多個線程去執行。

 

 

參考

https://www.cnblogs.com/yushangzuiyue/p/9655847.html

https://www.lmlphp.com/user/1387/article/item/29572

https://blog.csdn.net/oppo5630/article/details/79963490

https://www.cnblogs.com/liugx/p/9855612.html

https://blog.csdn.net/zhpengfei0915/article/details/80262817

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