一篇喫飽 Structured Streaming

目錄

 Structured Streaming 曲折發展史

Spark Streaming

Structured Streaming

主要優勢

編程模型

●核心思想

●應用場景

 ●WordCount圖解

Structured Streaming 實戰 (三種方式 WorldCount)

Socket source 方式

 讀取Socket數據

●準備工作

●代碼演示

效果 圖:​

Json    source 方式

●準備工作

●需求

●代碼演示

output mode

 output sink

●使用說明

Kafka   source  方式 

●Creating a Kafka Source for Streaming Queries

●注意:下面的參數是不能被設置的,否則kafka會拋出異常:

 整合環境準備

代碼實現

效果 :

整合MySQL

 

 Structured Streaming 曲折發展史

  1. Spark Streaming

Spark Streaming針對實時數據流,提供了一套可擴展、高吞吐、可容錯的流式計算模型。Spark Streaming接收實時數據源的數據,切分成很多小的batches,然後被Spark Engine執行,產出同樣由很多小的batchs組成的結果流。本質上,這是一種micro-batch(微批處理)的方式處理

不足在於處理延時較高(無法優化到秒以下的數量級), 無法支持基於event_time的時間窗口做聚合邏輯

  1. Structured Streaming

●官網

http://spark.apache.org/docs/latest/structured-streaming-programming-guide.html

●簡介

spark在2.0版本中發佈了新的流計算的API,Structured Streaming/結構化流。

Structured Streaming是一個基於Spark SQL引擎的可擴展、容錯的流處理引擎。統一了流、批的編程模型,可以使用靜態數據批處理一樣的方式來編寫流式計算操作。並且支持基於event_time的時間窗口的處理邏輯

隨着數據不斷地到達,Spark 引擎會以一種增量的方式來執行這些操作,並且持續更新結算結果。可以使用Scala、Java、Python或R中的DataSet/DataFrame API來表示流聚合、事件時間窗口、流到批連接等。此外,Structured Streaming會通過checkpoint和預寫日誌等機制來實現Exactly-Once語義。

簡單來說,對於開發人員來說,根本不用去考慮是流式計算,還是批處理,只要使用同樣的方式來編寫計算操作即可Structured Streaming提供了快速、可擴展、容錯、端到端的一次性流處理,而用戶無需考慮更多細節

默認情況下,結構化流式查詢使用微批處理引擎進行處理,該引擎將數據流作爲一系列小批處理作業進行處理,從而實現端到端的延遲,最短可達100毫秒,並且完全可以保證一次容錯。自Spark 2.3以來,引入了一種新的低延遲處理模式,稱爲連續處理,它可以在至少一次保證的情況下實現低至1毫秒的端到端延遲。也就是類似於 Flink 那樣的實時流,而不是小批量處理。實際開發可以根據應用程序要求選擇處理模式,但是連續處理在使用的時候仍然有很多限制,目前大部分情況還是應該採用小批量模式。

 

  1. API

 

1.Spark Streaming 時代 -DStream-RDD

Spark Streaming 採用的數據抽象是DStream,而本質上就是時間上連續的RDD,對數據流的操作就是針對RDD的操作

2.Structured Streaming 時代 - DataSet/DataFrame -RDD

Structured Streaming是Spark2.0新增的可擴展和高容錯性的實時計算框架,它構建於Spark SQL引擎,把流式計算也統一到DataFrame/Dataset裏去了。

Structured Streaming 相比於 Spark Streaming 的進步就類似於 Dataset 相比於 RDD 的進步

 

  1. 主要優勢

1.簡潔的模型。Structured Streaming 的模型很簡潔,易於理解。用戶可以直接把一個流想象成是無限增長的表格

2.一致的 API。由於和 Spark SQL 共用大部分 API,對 Spaprk SQL 熟悉的用戶很容易上手,代碼也十分簡潔。同時批處理和流處理程序還可以共用代碼,不需要開發兩套不同的代碼,顯著提高了開發效率。

3.卓越的性能。Structured Streaming 在與 Spark SQL 共用 API 的同時,也直接使用了 Spark SQL 的 Catalyst 優化器和 Tungsten,數據處理性能十分出色。此外,Structured Streaming 還可以直接從未來 Spark SQL 的各種性能優化中受益。

4.多語言支持。Structured Streaming 直接支持目前 Spark SQL 支持的語言,包括 Scala,Java,Python,R 和 SQL。用戶可以選擇自己喜歡的語言進行開發。

 

編程模型

●編程模型概述

一個流的數據源從邏輯上來說就是一個不斷增長的動態表格,隨着時間的推移,新數據被持續不斷地添加到表格的末尾

 

對動態數據源進行實時查詢,就是對當前的表格內容執行一次 SQL 查詢。

數據查詢,用戶通過觸發器(Trigger)設定時間(毫秒級)。也可以設定執行週期。

 

一個流的輸出有多種模式,既可以是基於整個輸入執行查詢後的完整結果,也可以選擇只輸出與上次查詢相比的差異,或者就是簡單地追加最新的結果

這個模型對於熟悉 SQL 的用戶來說很容易掌握,對流的查詢跟查詢一個表格幾乎完全一樣,十分簡潔,易於理解

 

核心思想

Structured Streaming最核心的思想就是將實時到達的數據不斷追加到unbound table無界表,到達流的每個數據項(RDD)就像是表中的一個新行被附加到無邊界的表中.這樣用戶就可以用靜態結構化數據的批處理查詢方式進行流計算,如可以使用SQL對到來的每一行數據進行實時查詢處理;(SparkSQL+SparkStreaming=StructuredStreaming)

●應用場景

Structured Streaming將數據源映射爲類似於關係數據庫中的表,然後將經過計算得到的結果映射爲另一張表,完全以結構化的方式去操作流式數據,這種編程模型非常有利於處理分析結構化的實時數據;

 ●WordCount圖解

如圖所示,

第一行表示從socket不斷接收數據,

第二行可以看成是之前提到的“unbound table",

第三行爲最終的wordCounts是結果集。

當有新的數據到達時,Spark會執行“增量"查詢,並更新結果集;

該示例設置爲Complete Mode(輸出所有數據),因此每次都將所有數據輸出到控制檯;

 

1.在第1秒時,此時到達的數據爲"cat dog"和"dog dog",因此我們可以得到第1秒時的結果集cat=1 dog=3,並輸出到控制檯;
2.當第2秒時,到達的數據爲"owl cat",此時"unbound table"增加了一行數據"owl cat",執行word count查詢並更新結果集,可得第2秒時的結果集爲cat=2 dog=3 owl=1,並輸出到控制檯;
3.當第3秒時,到達的數據爲"dog"和"owl",此時"unbound table"增加兩行數據"dog"和"owl",執行word count查詢並更新結果集,可得第3秒時的結果集爲cat=2 dog=4 owl=2;

這種模型跟其他很多流式計算引擎都不同。大多數流式計算引擎都需要開發人員自己來維護新數據與歷史數據的整合並進行聚合操作。然後我們就需要自己去考慮和實現容錯機制、數據一致性的語義等。然而在structured streaming的這種模式下,spark會負責將新到達的數據與歷史數據進行整合,並完成正確的計算操作,同時更新result table,不需要我們去考慮這些事情。

Structured Streaming 實戰 (三種方式 WorldCount)

  1. Source

spark 2.0中初步提供了一些內置的source支持。

Socket source (for testing): 從socket連接中讀取文本內容。

File source: 以數據流的方式讀取一個目錄中的文件。支持text、csv、json、parquet等文件類型。

Kafka source: 從Kafka中拉取數據,與0.10或以上的版本兼容,後面單獨整合Kafka

  1. Socket source 方式

 讀取Socket數據

 

●準備工作

nc -lk 9999

hadoop spark sqoop hadoop spark hive hadoop

●代碼演示

package StructuredStreaming


import org.apache.spark.SparkContext
import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}

/**
  * Created by 一個蔡狗 on 2020/4/16.
  */
object WordCount_Socket {


  def main(args: Array[String]): Unit = {
    val spark: SparkSession = SparkSession.builder().appName("ss").master("local[*]").getOrCreate()
    val sc: SparkContext = spark.sparkContext
    sc.setLogLevel("WARN")

    //通過 Spark.readStream 去調用輸入    數據就被傳到 了  frame,這個數據 並不是 字符串類型,
    //        .format ()   使用 什麼方式
    val frame: DataFrame = spark.readStream.format("socket")
      //  .option() 在 哪一個節點上面
      .option("host", "node001")
      //然後 什麼 端口
      .option("port", "9999")
      .load()

    //使用  隱式轉換  不然 下面 ($"count")  $ 會報錯

    import spark.implicits._

    // 處理數據    使用 as.[string]  轉換成 string  類型
     val unit: Dataset[String] = frame.as[String]

    //拿到 每一個 數據 進行 拆分
    val aa: Dataset[String] = unit.flatMap(_.split(" "))

    //拆分之後   進行   worldCount   算法
    val cc: Dataset[Row] = aa.groupBy("value").count().sort($"count")


    // 調用輸出
    cc.writeStream
      .format("console")  // 輸出 到哪裏去
      .outputMode("complete")  //每次將所有數據寫出
      .trigger(Trigger.ProcessingTime(0))   // 觸發時間間隔   0  儘量快的 跑
      .start()  //開啓
      .awaitTermination()  // 等待停止

  }


}

效果 圖:

  1. Json    source 方式

讀取目錄下文本數據

spark應用可以監聽某一個目錄,而web服務在這個目錄上實時產生日誌文件,這樣對於spark應用來說,日誌文件就是實時數據

Structured Streaming支持的文件類型有text,csv,json,parquet

●準備工作

在people.json文件輸入如下數據:

{"name":"json","age":23,"hobby":"running"}

{"name":"charles","age":32,"hobby":"basketball"}

{"name":"tom","age":28,"hobby":"football"}

{"name":"lili","age":24,"hobby":"running"}

{"name":"bob","age":20,"hobby":"swimming"}

注意:文件必須是被移動到目錄中的,且文件名不能有特殊字符

●需求

使用Structured Streaming統計年齡小於25歲的人羣的愛好排行榜

●代碼演示

package StructuredStreaming

package cn.itcast.structedstreaming

import org.apache.spark.SparkContext
import org.apache.spark.sql.streaming.Trigger
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}

/**
  * Created by 一個蔡狗 on 2020/4/16.
  */
object WordCount_json {

  def main(args: Array[String]): Unit = {

    //創建sparksession
    val spark: SparkSession = SparkSession.builder()
      .appName("StructStreamingFile")
      .master("local[*]")
      .getOrCreate()
    //設置日誌級別
    spark.sparkContext.setLogLevel("WARN")
    //讀取數據
    //設置數據的結構
    val structType: StructType = new StructType()
      // 添加 有那幾個字段
      .add("name", "string")
      .add("age", "integer")
      .add("hobby", "string")

    val fileDatas: DataFrame = spark.readStream.schema(structType).json("E:\\2020-傳智資料1\\第二學期Struct\\1")
    import spark.implicits._
    //計算數據  統計年齡小於25歲的人羣的愛好排行榜
    val hobby: Dataset[Row] = fileDatas.filter($"age" < 25).groupBy("hobby").count().sort($"count".asc)
    //數據輸出
    hobby.writeStream.format("console")
      .outputMode("complete")
      .start()
      .awaitTermination()
  }

}

效果 :

               

  1. 輸出

計算結果可以選擇輸出到多種設備並進行如下設定

1.output mode:以哪種方式將result table的數據寫入sink

2.format/output sink的一些細節:數據格式、位置等。

3.query name:指定查詢的標識。類似tempview的名字

4.trigger interval:觸發間隔,如果不指定,默認會盡可能快速地處理數據

5.checkpoint地址:一般是hdfs上的目錄。注意:Socket不支持數據恢復,如果設置了,第二次啓動會報錯 ,Kafka支持

  1. output mode

              

每當結果表更新時,我們都希望將更改後的結果行寫入外部接收器。

這裏有三種輸出模型:

1.Append mode:輸出新增的行,默認模式。每次更新結果集時,只將新添加到結果集的結果行輸出到接收器。僅支持添加到結果表中的行永遠不會更改的查詢。因此,此模式保證每行僅輸出一次。例如,僅查詢select,where,map,flatMap,filter,join等會支持追加模式。不支持聚合

2.Complete mode: 所有內容都輸出,每次觸發後,整個結果表將輸出到接收器。聚合查詢支持此功能。僅適用於包含聚合操作的查詢

3.Update mode: 輸出更新的行,每次更新結果集時,僅將被更新的結果行輸出到接收器(自Spark 2.1.1起可用),不支持排序

 

  1.  output sink

●使用說明

File sink 輸出到路徑

支持parquet文件,以及append模式

writeStream

    .format("parquet")        // can be "orc", "json", "csv", etc.

    .option("path", "path/to/destination/dir")

    .start()

Kafka sink 輸出到kafka內的一到多個topic

writeStream

    .format("kafka")

    .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

    .option("topic", "updates")

    .start()

Foreach sink 對輸出中的記錄運行任意計算。

writeStream

    .foreach(...)

    .start()

Console sink (for debugging) 當有觸發器時,將輸出打印到控制檯。

writeStream

    .format("console")

    .start()

Memory sink (for debugging) - 輸出作爲內存表存儲在內存中.

writeStream

    .format("memory")

    .queryName("tableName")

    .start()

 ●官網示例代碼

// ========== DF with no aggregations ==========

val noAggDF = deviceDataDf.select("device").where("signal > 10")   

// Print new data to console

noAggDF.writeStream.format("console").start()

// Write new data to Parquet files

noAggDF.writeStream.format("parquet").option("checkpointLocation", "path/to/checkpoint/dir").option("path", "path/to/destination/dir").start()

// ========== DF with aggregation ==========

val aggDF = df.groupBy("device").count()

// Print updated aggregations to console

aggDF.writeStream.outputMode("complete").format("console").start()

// Have all the aggregates in an in-memory table

aggDF.writeStream.queryName("aggregates").outputMode("complete").format("memory").start()

spark.sql("select * from aggregates").show()   // interactively query in-memory table

  1. Kafka   source  方式 

  1. StructuredStreaming整合Kafka
  1. 官網介紹

http://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html

Creating a Kafka Source for Streaming Queries

 

// Subscribe to 1 topic

val df = spark

  .readStream

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribe", "topic1")

  .load()

df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

// Subscribe to multiple topics(多個topic)

val df = spark

  .readStream

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribe", "topic1,topic2")

  .load()

df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

// Subscribe to a pattern(訂閱通配符topic)

val df = spark

  .readStream

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribePattern", "topic.*")

  .load()

df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

Creating a Kafka Source for Batch Queries(kafka批處理查詢)

// Subscribe to 1 topic

//defaults to the earliest and latest offsets(默認爲最早和最新偏移)

val df = spark

  .read

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribe", "topic1")

  .load()df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

// Subscribe to multiple topics, (多個topic)

//specifying explicit Kafka offsets(指定明確的偏移量)

val df = spark

  .read

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribe", "topic1,topic2")

  .option("startingOffsets", """{"topic1":{"0":23,"1":-2},"topic2":{"0":-2}}""")

  .option("endingOffsets", """{"topic1":{"0":50,"1":-1},"topic2":{"0":-1}}""")

  .load()df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

// Subscribe to a pattern, (訂閱通配符topic)at the earliest and latest offsets

val df = spark

  .read

  .format("kafka")

  .option("kafka.bootstrap.servers", "host1:port1,host2:port2")

  .option("subscribePattern", "topic.*")

  .option("startingOffsets", "earliest")

  .option("endingOffsets", "latest")

  .load()df.selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")

  .as[(String, String)]

●注意:讀取後的數據的Schema是固定的,包含的列如下:

Column

Type

說明

key

binary

消息的key

value

binary

消息的value

topic

string

主題

partition

int

分區

offset

long

偏移量

timestamp

long

時間戳

timestampType

int

類型

●注意:下面的參數是不能被設置的,否則kafka會拋出異常:

  • group.id:kafka的source會在每次query的時候自定創建唯一的group id
  • auto.offset.reset :爲了避免每次手動設置startingoffsets的值,structured streaming在內部消費時會自動管理offset。這樣就能保證訂閱動態的topic時不會丟失數據。startingOffsets在流處理時,只會作用於第一次啓動時,之後的處理都會自動的讀取保存的offset。
  • key.deserializer,value.deserializer,key.serializer,value.serializer 序列化與反序列化,都是ByteArraySerializer
  • enable.auto.commit:Kafka源不支持提交任何偏移量
  1.  整合環境準備

●啓動kafka

cd /export/servers/kafka_2.11-1.0.0

nohup bin/kafka-server-start.sh config/server.properties 2>&1 &
 

●向topic中生產數據

cd /export/servers/kafka_2.11-1.0.0

bin/kafka-console-producer.sh --broker-list node001:9092,node002:9092,node003:9092 --topic test


代碼實現

package StructuredStreaming



import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}

/**
  * Created by 一個蔡狗 on 2020/4/16.
  */
object StructStreaming_kafka {
  def main(args: Array[String]): Unit = {
    //準備環境
    val spark: SparkSession = SparkSession.builder()
      .appName("strut")
      .master("local[*]")
      .getOrCreate()
    //設置日誌級別
    spark.sparkContext.setLogLevel("WARN")

    //讀取數據      .format("kafka")  讀取哪裏的 數據  kafka 的數據
    val kafkaDatas: DataFrame = spark.readStream.format("kafka")
      //設置集羣  節點
      .option("kafka.bootstrap.servers","node001:9092,node002:9092,node003:9092")
      // subscribe   訂閱  哪一個   topic
      .option("subscribe","18BD12")
      //load' 一下 加載數據
      .load()

    //查看是什麼
//    kafkaDatas.flatMap(a=>{
//
//      val a1: Row = a
//
//    })
    //kafkaDatas  內的數據是kafka的數據(key,value)
    // 所以 轉換一下 selectExpr 轉化 使用 CAST 類型轉換  key是轉換之前的類型
    import  spark.implicits._
    val kafkaDatasString: Dataset[(String, String)] = kafkaDatas.selectExpr("CAST(key AS string)","CAST(value AS string)").as[(String,String)]
    //處理      value是哪行數據
    val word: Dataset[String] = kafkaDatasString.flatMap(a=>a._2.split(" "))
    //調用DSL語句進行計算
    val wordCount: Dataset[Row] = word.groupBy("value").count().sort($"count".desc)

    //輸出    輸出到 控制檯
    wordCount.writeStream.format("console")
      .outputMode("complete")
      .start()
      .awaitTermination()   // 等待結束

  }
}

效果 :

整合MySQL

  1. 簡介

●需求

我們開發中經常需要將流的運算結果輸出到外部數據庫,例如MySQL中,但是比較遺憾Structured Streaming API不支持外部數據庫作爲接收器

如果將來加入支持的話,它的API將會非常的簡單比如:

format("jdbc").option("url","jdbc:mysql://...").start()

但是目前我們只能自己自定義一個JdbcSink,繼承ForeachWriter並實現其方法

 

●參考網站

https://databricks.com/blog/2017/04/04/real-time-end-to-end-integration-with-apache-kafka-in-apache-sparks-structured-streaming.html

  1. 代碼演示

  2. package SqlStruct
    
    import java.sql.{Connection, DriverManager, PreparedStatement}
    
    import org.apache.spark.sql._
    
    /**
      * Created by 一個蔡狗 on 2020/4/17.
      */
    object sql_01 {
    
    
      def main(args: Array[String]): Unit = {
        //準備環境
        val spark: SparkSession = SparkSession.builder()
          .appName("strut")
          .master("local[*]")
          .getOrCreate()
        //設置日誌級別
        spark.sparkContext.setLogLevel("WARN")
    
        //讀取數據      .format("kafka")  讀取哪裏的 數據  kafka 的數據
        val kafkaDatas: DataFrame = spark.readStream.format("kafka")
          //設置集羣  節點
          .option("kafka.bootstrap.servers", "node001:9092,node002:9092,node003:9092")
          // subscribe   訂閱  哪一個   topic
          .option("subscribe", "18BD12")
          //load' 一下 加載數據
          .load()
    
        //查看是什麼
        //    kafkaDatas.flatMap(a=>{
        //
        //      val a1: Row = a
        //
        //    })
        //kafkaDatas  內的數據是kafka的數據(key,value)
        // 所以 轉換一下 selectExpr 轉化 使用 CAST 類型轉換  key是轉換之前的類型
        import spark.implicits._
        val kafkaDatasString: Dataset[(String, String)] = kafkaDatas.selectExpr("CAST(key AS string)", "CAST(value AS string)").as[(String, String)]
        //處理      value是哪行數據
        val word: Dataset[String] = kafkaDatasString.flatMap(a => a._2.split(" "))
        //調用DSL語句進行計算
        val wordCount: Dataset[Row] = word.groupBy("value").count().sort($"count".desc)
    
        //輸出到  mysql 裏面
        var intoMysql = new intoMysql("jdbc:mysql://node002:3306/bigdata?characterEncoding=UTF-8", "root", "123456");
    
    
        //輸出    輸出到 控制檯   //拿到 wordCount 的每一行
        wordCount.writeStream.foreach(intoMysql)
          .outputMode("complete")
          .start()
          .awaitTermination() // 等待結束
    
      }
    
      //編寫  將數據   更新插入 mysql 數據庫的 代碼
      class intoMysql(url: String, username: String, password: String) extends ForeachWriter[Row] with Serializable {
        //準備一個 連接 對象
        var connection: Connection = _ //_表示佔位符,後面會給變量賦值
    
        //設置 sql 對象
        var preparedStatement: PreparedStatement = _
    
        // 用於打開  數據庫連接
        override def open(partitionId: Long, version: Long): Boolean = {
          //獲取連接
          connection = DriverManager.getConnection(url, username, password)
          //獲取連接無錯誤  返回 true
          true
        }
        // hive haha heihei
    
        //用於更新 \ 插入 數據到  mysql
        override def process(value: Row): Unit = {
          // value 內的第一個數據 是單詞
          var word = value.get(0).toString
    
          // value 內的第二個數據 是單詞的 數量
          var count = value.get(1).toString.toInt
    
          println("word : " + word + "          count : " + count)
    
          //編寫  數據   插入 語句
          //REPLACE INTO:表示如果表中沒有數據這插入,如果有數據則替換
          //注意:REPLACE INTO要求表有主鍵或唯一索引
            var sql = "REPLACE  into  t_word_counts  (id,word,count) values  (Null,?,?)"
    
          //傳入 語句
          preparedStatement = connection.prepareStatement(sql)
          preparedStatement.setString(1, word)
          preparedStatement.setInt(2, count)
          //  提高    插入 執行
          preparedStatement.executeUpdate()
    
    
        }
    
        //關閉數據庫 連接
        override def close(errorOrNull: Throwable): Unit = {
    
          if (connection != null) {
            connection.close()
          }
          if (preparedStatement != null) {
            preparedStatement.close()
          }
    
        }
    
    
      }
    
    
    }
    
    

    效果:

 

建表語句 

CREATE TABLE `t_word_counts` (
        `id` int(11) NOT NULL AUTO_INCREMENT,
        `word` varchar(255) NOT NULL,
        `count` int(11) DEFAULT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `word` (`word`)
      ) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8;

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