阿里 sentinel dashboard源碼實現請求數據監控統計與持久化展示數據,集成spring cloud多環境打包jar包運行

  最近在做一個要求統計api-gateway模塊的所有請求數據展示的一個功能,所以查找了幾個方案,包括以下:

1,alibaba sentinel 哨兵(最後決定使用這個,因爲功能比較豐富,以後可能可以用到,而且想試一下阿里的框架,控制檯dashboard的前端實時請求展示用的框架也是螞蟻金服的G2可視化圖表框架)

2,大衆點評(這個功能很完善,而且也很豐富,完全符合要求,後端需要部署,前端集成,但是查資料網友說不支持api-gateway,所以放棄了)

3,spring boot Admin(主要針對服務的監控)

4,Hystrix Dashboard(單個微服務監控)+ Turbine 聚合監控(集羣微服務監控)(可以監控,但是一些統計界面還是要自己統計實現)

5,Spring Cloud Sleuth + Zipkin +ELK(雖然集成了ELK,但是這個工程比較麻煩)

6,springboot  aop 切面攔截http請求(可以攔截,但要考慮到數據量持久化的性能問題)

以上使用到的方案,基本都試驗過了,最後決定還是用阿里的哨兵吧:

     附上github地址:https://github.com/alibaba/Sentinel/wiki (手動點擊看介紹)

現在的版本已經更新到1.6了,因爲本人目前只是需要其中的一個數據請求統計的功能,所以就只下載了sentinel dashboard的源碼來操作,修改裏面的東西,實現自己想要的功能。

sentinel dashboard集成spring cloud的操作非常簡單,只需要配置pom依賴和配置文件dashboard的地址就能連上sentinel dashboard控制檯了;

第一步,在需要的監控的模塊,配置依賴pom: 由於項目的spring boot版本是1.4.1的低版本,所以目前可以依賴0.1.0.RELEASE包,依賴0.2.0.RELEASE或以上的話就會報錯,目前已經更新到0.9.0.RELEASE依賴包

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    <version>0.1.0.RELEASE</version>
</dependency>

第二步,配置文件配置dashboard訪問地址:(這裏我改成了31017的訪問地址,防止和其他端口衝突)

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:31017

好了,以上兩步spring cloud配置就集成了sentinel dashboard了,簡單吧

第三步,網上下載運行sentinel dashboard.jar包,一開始我下載的是網上已經打包好的1.6.0-jar包的,不過很抱歉,那個網址不知道扔哪裏去了,不過既然下載了源碼下來,我們也可以自己打包一個jar給自己用,而且後面的操作都會涉及到源碼,打開源碼,切到sentinel dashboard的模塊下,在終端運行打包命令:mvn clean package

生成target包,進去裏面找打包好的jar包,並且運行此jar包,命令:java -jar sentinel-dashboard.jar 

注意:打包的源碼中的配置文件application.properties,記得修改裏面的端口號爲:31017

添加代碼:     server.port = 31017

前端訪問地址:gulpfile.js文件,拉倒最下面看到打開瀏覽器那裏,修改你的前端訪問端口號:

運行起來,就可以在瀏覽器打開鏈接:localhost:31017,看到控制檯的頁面了,如下(爲了展示,下面兩張圖都是取別人的):

不修改其他代碼的話,大致實現圖如下:(左邊是圖表展示格式,右邊是表格展示格式)

 

圖表最左邊是一堆功能選項列表,其中我想要的功能就在實時監控裏面,這裏展示的數據是實時的,並且是秒級展示的。

引用官網的一段話:Sentinel 會記錄資源訪問的秒級數據(若沒有訪問則不進行記錄)並保存在本地日誌中,具體格式請見 秒級監控日誌文檔。Sentinel 控制檯可以通過 Sentinel 客戶端預留的 HTTP API 從秒級監控日誌中拉取監控數據,並進行聚合。

       目前 Sentinel 控制檯中監控數據聚合後直接存在內存中,還沒有進行持久化,且僅保留最近 5 分鐘的監控數據。若需要監控數據持久化的功能,可以擴展實現 MetricsRepository 接口(0.2.0 版本);

注意:網上給出的持久化到配置中心apollo或者nacox等,實際上主要是持久化規則而已,實現數據持久化展示,還得自己持久化到自己的數據庫中

但是我想要的功能其實不需要這麼多,暫時只是需要實時的功能,所以其他功能用來作爲以後續項目需求再拓展使用,暫先屏蔽

下面先給出我改造後的功能圖:

左邊列表暫時不需要用到的功能-前後端都屏蔽了,只留下了個實時功能,目前的效果是因爲訪問數據量過於龐大,故數據庫裏只保存了前6個小時以內的數據,超過6個小時便會刪除數據,前端頁面一分鐘請求統計一次,亦可點擊搜索按鈕直接請求,後端實時攔截數據,內存保存時間改爲1分鐘,過濾其他不需要統計的請求url,前端圖表只留下柱狀圖,並按分鐘統計展示(取消秒級統計),曾測試過10萬條假數據,瀏覽器很容易奔潰,減至5千條也仍然有此隱患,故前端修改爲按分頁懶加載查詢,每頁最大3千條數據統計爲準。

下面一一給出持久化到數據庫以及前後端修改的源碼:

1,創建數據庫:ssc_sentinel,並創建數據表sentinel_metric(建表語句如下):

CREATE TABLE `sentinel_metric` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id,主鍵',
  `gmt_create` datetime DEFAULT NULL COMMENT '創建時間',
  `gmt_modified` datetime DEFAULT NULL COMMENT '修改時間',
  `app` varchar(100) DEFAULT NULL COMMENT '應用名稱',
  `timestamp` datetime DEFAULT NULL COMMENT '統計時間',
  `resource` varchar(500) DEFAULT NULL COMMENT '資源名稱',
  `pass_qps` int(11) DEFAULT NULL COMMENT '通過qps',
  `success_qps` int(11) DEFAULT NULL COMMENT '成功qps',
  `block_qps` int(11) DEFAULT NULL COMMENT '限流qps',
  `exception_qps` int(11) DEFAULT NULL COMMENT '發送異常的次數',
  `rt` double DEFAULT NULL COMMENT '耗時時間(ms)',
  `_count` int(11) DEFAULT NULL COMMENT '本次聚合的總條數',
  `resource_code` int(11) DEFAULT NULL COMMENT '資源的hashCode',
  PRIMARY KEY (`id`),
  KEY `app_idx` (`app`) USING BTREE,
  KEY `resource_idx` (`resource`) USING BTREE,
  KEY `timestamp_idx` (`timestamp`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=105043 DEFAULT CHARSET=utf8;

 

2,依賴jap包和mysql驅動包:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>${spring.boot.version}</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>RELEASE</version>
    <scope>compile</scope>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.38</version>
</dependency>

3,配置文件添加數據庫連接和jap配置:

# datasource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ssc_sentinel?characterEncoding=UTF-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=

# spring data jpa
spring.jpa.hibernate.ddl-auto=none
spring.jpa.hibernate.use-new-id-generator-mappings=false
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.show-sql=false

4,創建實體MetricPO,實例化數據表:

package com.alibaba.csp.sentinel.dashboard.datasource.entity.jpa;

/**
 * @author KL
 * @version 1.0
 * @date 2019/7/29
 **/
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;

@Entity
@Table(name = "sentinel_metric")
public class MetricPO implements Serializable {

    private static final long serialVersionUID = 7200023615444172715L;

    /**id,主鍵*/
    @Id
    @GeneratedValue
    @Column(name = "id")
    private Long id;

    /**創建時間*/
    @Column(name = "gmt_create")
    private Date gmtCreate;

    /**修改時間*/
    @Column(name = "gmt_modified")
    private Date gmtModified;

    /**應用名稱*/
    @Column(name = "app")
    private String app;

    /**統計時間*/
    @Column(name = "timestamp")
    private Date timestamp;

    /**資源名稱*/
    @Column(name = "resource")
    private String resource;

    /**通過qps*/
    @Column(name = "pass_qps")
    private Long passQps;

    /**成功qps*/
    @Column(name = "success_qps")
    private Long successQps;

    /**限流qps*/
    @Column(name = "block_qps")
    private Long blockQps;

    /**發送異常的次數*/
    @Column(name = "exception_qps")
    private Long exceptionQps;

    /**耗時時間(ms)*/
    @Column(name = "rt")
    private Double rt;

    /**本次聚合的總條數*/
    @Column(name = "_count")
    private Integer count;

    /**資源的hashCode*/
    @Column(name = "resource_code")
    private Integer resourceCode;

    public static long getSerialVersionUID() {
        return serialVersionUID;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Date getGmtCreate() {
        return gmtCreate;
    }

    public void setGmtCreate(Date gmtCreate) {
        this.gmtCreate = gmtCreate;
    }

    public Date getGmtModified() {
        return gmtModified;
    }

    public void setGmtModified(Date gmtModified) {
        this.gmtModified = gmtModified;
    }

    public String getApp() {
        return app;
    }

    public void setApp(String app) {
        this.app = app;
    }

    public Date getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(Date timestamp) {
        this.timestamp = timestamp;
    }

    public String getResource() {
        return resource;
    }

    public void setResource(String resource) {
        this.resource = resource;
    }

    public Long getPassQps() {
        return passQps;
    }

    public void setPassQps(Long passQps) {
        this.passQps = passQps;
    }

    public Long getSuccessQps() {
        return successQps;
    }

    public void setSuccessQps(Long successQps) {
        this.successQps = successQps;
    }

    public Long getBlockQps() {
        return blockQps;
    }

    public void setBlockQps(Long blockQps) {
        this.blockQps = blockQps;
    }

    public Long getExceptionQps() {
        return exceptionQps;
    }

    public void setExceptionQps(Long exceptionQps) {
        this.exceptionQps = exceptionQps;
    }

    public Double getRt() {
        return rt;
    }

    public void setRt(Double rt) {
        this.rt = rt;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }

    public Integer getResourceCode() {
        return resourceCode;
    }

    public void setResourceCode(Integer resourceCode) {
        this.resourceCode = resourceCode;
    }
}

5,找到MetricsRepository,並添加下面幾個方法(分頁查詢,刪除等)

List<String> listResourcesOfApp(String app);

List<T> queryByTime(Integer pageIndex, Integer pageSize,String key);

Integer countByTime(String key);

void removeAll();

6,新增一個dao繼承MetricsRepository,實現查詢與持久化操作,代碼如下:

package com.alibaba.csp.sentinel.dashboard.repository.metric;

/**
 * @author KL
 * @version 1.0
 * @date 2019/7/29
 **/

import com.alibaba.csp.sentinel.dashboard.datasource.entity.MetricEntity;
import com.alibaba.csp.sentinel.dashboard.datasource.entity.jpa.MetricPO;
import com.alibaba.csp.sentinel.util.StringUtil;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.Query;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;

@Transactional
@Repository("jpaMetricsRepository")
public class JpaMetricsRepository implements MetricsRepository<MetricEntity> {

   @PersistenceContext
   private EntityManager em;

   @Override
   public void save(MetricEntity metric) {
      if (metric == null || StringUtil.isBlank(metric.getApp())) {
         return;
      }


      MetricPO metricPO = new MetricPO();
      BeanUtils.copyProperties(metric, metricPO);

      StringBuilder hql = new StringBuilder();
      hql.append("FROM MetricPO");
      hql.append(" WHERE resource=:resource");
      hql.append(" AND timestamp>=:startTime");
      hql.append(" AND timestamp<=:endTime");
      Query query = em.createQuery(hql.toString());
      query.setParameter("resource", metricPO.getResource());
      query.setParameter("startTime", Date.from(Instant.ofEpochMilli(metricPO.getTimestamp().getTime() - (metricPO.getTimestamp().getSeconds() * 1000))));
      query.setParameter("endTime", Date.from(Instant.ofEpochMilli(metricPO.getTimestamp().getTime() - (metricPO.getTimestamp().getSeconds() * 1000) + 59 * 1000)));
      query.setMaxResults(1);
      List<MetricPO> metricPOs = query.getResultList();
      if (CollectionUtils.isEmpty(metricPOs)) {
         em.persist(metricPO);
      } else {
         MetricPO saveVo = metricPOs.get(0);
         saveVo.setPassQps(metricPO.getPassQps() + saveVo.getPassQps());
         saveVo.setBlockQps(metricPO.getBlockQps() + saveVo.getBlockQps());
         saveVo.setSuccessQps(metricPO.getSuccessQps() + saveVo.getSuccessQps());
         saveVo.setExceptionQps(metricPO.getExceptionQps() + saveVo.getExceptionQps());
         saveVo.setRt(metricPO.getRt() + saveVo.getRt());
         saveVo.setCount(metricPO.getCount() + saveVo.getCount());
         saveVo.setTimestamp(metricPO.getTimestamp());
         saveVo.setGmtModified(metricPO.getGmtModified());
         em.merge(saveVo);
      }
   }

   @Override
   public void saveAll(Iterable<MetricEntity> metrics) {
      if (metrics == null) {
         return;
      }
      removeAll();
      metrics.forEach(this::save);
   }

   @Override
   public List<MetricEntity> queryByAppAndResourceBetween(String app, String resource, long startTime, long endTime) {
      List<MetricEntity> results = new ArrayList<MetricEntity>();
      return results;
   }

   @Override
   public List<MetricEntity> queryByTime(Integer pageIndex,Integer pageSize,String key) {
      List<MetricEntity> results = new ArrayList<MetricEntity>();

      StringBuilder hql = new StringBuilder();
      hql.append("FROM MetricPO");
      hql.append(" WHERE timestamp<=:endTime");
      if (StringUtil.isNotBlank(key)){
         hql.append(" AND resource LIKE :key ");
      }

      hql.append(" order by timestamp desc");


      Query query = em.createQuery(hql.toString());
      query.setMaxResults(pageSize);
      query.setFirstResult((pageIndex-1)*pageSize);
      query.setParameter("endTime", Date.from(Instant.ofEpochMilli(System.currentTimeMillis())));
      if (StringUtil.isNotBlank(key)){
         query.setParameter("key","%"+key+"%");
      }

      List<MetricPO> metricPOs = query.getResultList();
      if (CollectionUtils.isEmpty(metricPOs)) {
         return results;
      }

      for (MetricPO metricPO : metricPOs) {
         if (metricPO.getGmtCreate().after(new Date(System.currentTimeMillis() - 6 * 3600 * 1000))) {
            MetricEntity metricEntity = new MetricEntity();
            BeanUtils.copyProperties(metricPO, metricEntity);
            results.add(metricEntity);
         }
      }

      return results;
   }

   @Override
   public Integer countByTime(String key) {
      Integer totalCount = 0;

      StringBuilder hql = new StringBuilder();
      hql.append("FROM MetricPO");
      hql.append(" WHERE timestamp<=:endTime");
      if (StringUtil.isNotBlank(key)){
         hql.append(" AND resource LIKE :key ");
      }
      Query query = em.createQuery(hql.toString());
      query.setParameter("endTime", Date.from(Instant.ofEpochMilli(System.currentTimeMillis())));
      if (StringUtil.isNotBlank(key)){
         query.setParameter("key","%"+key+"%");
      }

      List<MetricPO> metricPOs = query.getResultList();
      if (CollectionUtils.isEmpty(metricPOs)) {
         return totalCount;
      }

      for (MetricPO metricPO : metricPOs) {
         if (metricPO.getGmtCreate().after(new Date(System.currentTimeMillis() - 6 * 3600 * 1000))) {
            MetricEntity metricEntity = new MetricEntity();
            BeanUtils.copyProperties(metricPO, metricEntity);
            totalCount++;
         }
      }

      return totalCount;
   }

   @Override
   public void removeAll() {
      StringBuilder hql = new StringBuilder();
      hql.append("FROM MetricPO");
      hql.append(" WHERE timestamp<:time");
      Query query = em.createQuery(hql.toString());
      query.setParameter("time", Date.from(Instant.ofEpochMilli(System.currentTimeMillis() - 6 * 3600 * 1000)));
      List<MetricPO> metricPOs = query.getResultList();
      if (CollectionUtils.isEmpty(metricPOs)) {
         return;
      }
      for (MetricPO metricPO : metricPOs) {
         //超過6個小時則刪除
         em.remove(metricPO);
      }
   }

   @Override
   public List<String> listResourcesOfApp(String app) {
      return null;
   }
}

7,因爲InMemoryMetricsRepository類中也繼承了MetricsRepository接口,所以記得實現上面新增的幾個方法,不用操作,實現就行:

8,MetricFetcher類中添加@Qualifier("jpaMetricsRepository")註解,如下位置:

@Qualifier("jpaMetricsRepository")
@Autowired
private MetricsRepository<MetricEntity> metricStore;

9,MetricController類中也同樣添加@Qualifier("jpaMetricsRepository")註解,如下位置:

@Qualifier("jpaMetricsRepository")
@Autowired
private MetricsRepository<MetricEntity> metricStore;

10,修改MetricController類中的@RequestMapping("/queryTopResourceMetric.json")對應的請求方法,此方法主要是實現展示數據庫中持久化的數據,修改如下:

@Autowired
private JpaMetricsRepository jpaMetricsRepository1;

@ResponseBody
@RequestMapping("/queryTopResourceMetric.json")
public Result<?> queryTopResourceMetric(final String app,
                                        Integer pageIndex,
                                        Integer pageSize,
                                        Boolean desc,
                                        Long startTime, Long endTime, String searchKey) {

 List<String> topResource = new ArrayList<>();

    final Map<String, Iterable<MetricVo>> map = new ConcurrentHashMap<>();

 Integer totalCount = 0;
 Integer totalPage = 0;
    totalCount = jpaMetricsRepository1.countByTime(searchKey);
    if (totalCount==0){
        return Result.ofSuccess(null);
    }
    totalPage = (totalCount + pageSize - 1) / pageSize;
    if(StringUtil.isNotBlank(searchKey)&&totalPage==1){
        pageIndex = 1;
    }
        List<MetricEntity> entities = jpaMetricsRepository1.queryByTime(pageIndex,pageSize,searchKey);
        if (entities!=null && entities.size()!=0) {
           for (MetricEntity entity:entities) {
              String res = entity.getResource();
          List<MetricVo> vos = MetricVo.fromMetricEntities(entities, res);
          map.put(res, vos);
         }
         topResource = entities.stream().map(MetricEntity::getResource).collect(Collectors.toList());

        }
 if (topResource == null || topResource.isEmpty()) {
  return Result.ofSuccess(null);
 }

    Map<String, Object> resultMap = new HashMap<>(16);
    resultMap.put("totalCount", totalCount);
    resultMap.put("totalPage", totalPage);
    resultMap.put("pageIndex", pageIndex);
    resultMap.put("pageSize", pageSize);

    Map<String, Iterable<MetricVo>> map2 = new LinkedHashMap<>();
    // order matters.
    for (String identity : topResource) {
        map2.put(identity, map.get(identity));
    }
    resultMap.put("metric", map2);
    return Result.ofSuccess(resultMap);
}

11,修改DashboardConfig,根據自己需要修改,這裏設置了隱藏時間爲一分鐘,最好對應前端一分鐘之後請求統計一次的時間來修改,所示:

public static int getHideAppNoMachineMillis() {
    return getConfigInt(CONFIG_HIDE_APP_NO_MACHINE_MILLIS, 0, 60000);
}

public static int getRemoveAppNoMachineMillis() {
    return getConfigInt(CONFIG_REMOVE_APP_NO_MACHINE_MILLIS, 0, 120000);
}

public static int getAutoRemoveMachineMillis() {
    return getConfigInt(CONFIG_AUTO_REMOVE_MACHINE_MILLIS, 0, 180000);
}

public static int getUnhealthyMachineMillis() {
    return getConfigInt(CONFIG_UNHEALTHY_MACHINE_MILLIS, DEFAULT_MACHINE_HEALTHY_TIMEOUT_MS, 30000);
}

參考配置文件說明:

sentinel.dashboard.auth.username | String | sentinel | 無 | 登錄控制檯的用戶名,默認爲 `sentinel`
sentinel.dashboard.auth.password | String | sentinel | 無 | 登錄控制檯的密碼,默認爲 `sentinel`
sentinel.dashboard.app.hideAppNoMachineMillis | Integer | 0 | 60000 | 是否隱藏無健康節點的應用,距離最近一次主機心跳時間的毫秒數,默認關閉
sentinel.dashboard.removeAppNoMachineMillis | Integer | 0 | 120000 | 是否自動刪除無健康節點的應用,距離最近一次其下節點的心跳時間毫秒數,默認關閉
sentinel.dashboard.unhealthyMachineMillis | Integer | 60000 | 30000 | 主機失聯判定,不可關閉
sentinel.dashboard.autoRemoveMachineMillis | Integer | 0 | 300000 | 距離最近心跳時間超過指定時間是否自動刪除失聯節點,默認關閉

 

12,修改了時間後,打包時,會運行測試類,設置的斷言assert可能會報錯,只需要修改時間,成功打包即可:

13,修改前端metric.js文件,修改自動刷新時間和初始化pageSize等:

14,sidebar.html前端註釋不需要展示的列,後端請求代碼根據需要自行註釋:

15,app.js註釋請求路由,停止請求:

 

16,metric.html註釋表格:

17,配置url過濾配置以及賬號密碼:

18,配置多環境打包圖:

對應的application添加對應的active,例如local本地環境添加配置文件:spring.profiles.active=local

19,配置修改pom多環境配置(兩處):

添加profile

<profiles>
    <profile>
        <id>local</id>
        <properties>
            <profiles.active>local</profiles.active>
        </properties>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
    </profile>
    <profile>
        <id>dev</id>
        <properties>
            <profiles.active>dev</profiles.active>
        </properties>
    </profile>
    <profile>
        <id>dev_v30</id>
        <properties>
            <profiles.active>dev_v30</profiles.active>
        </properties>
    </profile>
</profiles>

添加修改recourse

<resources>
    <resource>
        <directory>src/main/webapp/</directory>
        <excludes>
            <exclude>resources/node_modules/**</exclude>
        </excludes>
    </resource>

    <resource>
        <directory>src/main/resources</directory>
    </resource>
    <resource>
        <directory>src/main/resources</directory>
        <excludes>
            <exclude>application-{profiles.active}.properties</exclude>
        </excludes>
        <filtering>true</filtering>
    </resource>
</resources>

20,終端運行打包:mvn clean package,jar包生成目錄在target下

 

21,不同環境jar包運行命令,如本地環境:java -jar sentinel-dashboard.jar --spring.profiles.active=local

 

以上,就是集成sentinel dashboard到spring cloud 中實現數據(按分鐘)持久化統計展示,多環境運行單個jar的例子了,

最後部署到docker鏡像裏面,通過上面運行命令即可部署。

 

後續拓展功能:添加所有請求的排行榜統計頁面如下(其中小於1.5S耗時不展示):

 

主要參考文檔如下:

1,持久化mysql:https://www.cnblogs.com/cdfive2018/p/9838577.html

2,源碼結構:https://www.jianshu.com/p/affbb66c15e6

3,官網wiki:https://github.com/alibaba/Sentinel/wiki 

                                                                                                                                                

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