Apache Kafka ฉบับผู้เริ่มต้น #2: Core concepts

Neng Liangpornrattana
LINE Developers Thailand
6 min readJun 21, 2018

โพสก่อนผมพาไปทำความรู้จัก Apache Kafka แบบคร่าวๆ โพสนี้ ผมจะพาลงลึกขึ้นอีกนิดนึง ในการทำความเข้าใจและรู้จัก Apache Kafka ให้มากขึ้น

มีสองสิ่ง ที่จะต้องทำความเข้าใจกับมันก่อน คือสิ่งที่เรียกว่า Topics แหละ Partitions ซึ่งสองสิ่งนี้ ทำหน้าที่จัดกลุ่มของข้อมูลที่เราจะเก็บไว้ มีข้อกำหนด มีคุณสมบัติหลายอย่าง ที่จำเป็นต้องรู้จักก่อนเอาไปใช้งานจริง

Topics

Topics เป็นชุดของข้อมูล(หรือ Messages) มีลักษณะเหมือนกับ table ใน database ที่เราคุ้นเคยกัน(แต่ไม่มีตัวเชื่อมโยงในแต่ละ topic นะ) ซึ่งแต่ละ topic เนี่ย จะถูกตั้งชื่อได้ เพื่อให้เรารู้ว่ามันคือข้อมูลเกี่ยวกับอะไร ซึ่งชื่อก็ไม่ควร(จริงๆ ห้าม) ซ้ำกัน ไม่งั้นตอนเอาไปใช้งานก็จะแยกชุดของข้อมูลไม่ได้

Partitions

ข้อมูลในแต่ละ Topic จะถูกแยกเป็นกลุ่มๆ อีก ซึ่งแต่ละกลุ่มนี้ จะถูกเรียกว่า Partition ข้อมูลในแต่ละ partition จะถูกจัดเรียง(ส่วนเรียกตามอะไร เดี๋ยวจะมาอธิบายต่อครับ) ข้อมูลแต่ละส่วน หรือแต่ละ message เนี่ย จะมีค่าบางอย่างที่มีลักษณะเพิ่มเองได้(incremental) ซึ่งจะเริ่มที่ 0 และเพิ่มค่าเป็น 1 2 3… ไปเรื่อยๆ ตามจำนวนของข้อมูล ซึ่งมันจะถูกเรียกว่า offset หรือตัวนับในแต่ละ partition

ลองดูการอธิบายข้างล่างเพื่อให้เห็นภาพที่ตรงกัน โดยเริ่มที่ partition แรก จะเรียกว่า partition 0

เริ่มต้นที่ยังไม่มีค่าอะไร พอข้อมูลถูกเพิ่มมาครั้งแรก ตัว offset จะถูกนับเป็น 0 จากนั้น ถูกเพิ่มมาเรื่อยๆ ก็นับเพิ่มตามมาเรือยๆ ตามลำดับ

จากในตัวอย่าง offset ล่าสุดคือคือ 5 และตัว partition จะรู้ได้ว่าตัวถัดไปจะต้องเป็นตัวที่ 6

ซึ่งถ้าเราเตรียมไว้หลาย patition ใน topic ของเรา ข้อมูลจะถูกกระจายไปอยู่ในแต่ละ paritition เอง

ตัวอย่างด้านบน สมมติว่าเรามี 3 partitions ใน 1 topic ซึ่งในแต่ละ partition จะมีข้อมูลที่ถูกกระจายมาแล้ว ในแต่ละ partition จะมีตัวนับ(offset)ซึ่งซ้ำกันได้ แต่จะไม่ใช่ข้อมูลชุดเดียวกันในแต่ละ partition เช่น เวลาอ้างถึง id 1 ใน partition 0 และ partition 1 จะเป็นข้อมูลคนละตัวกัน

คุณสมบัติเบื้องต้นของ Topics และ Partitions

  • offset จะใช้อ้างในแต่ละ partition เท่านั้น ตามที่อธิบายไปด้านบนแล้ว
  • ข้อมูลจะถูกเรียงตามลำดับก่อนหลัง ใน partition นั้นๆ แปลว่า offset ที่ 0 ของ partition ที่ 0 อาจจะมาก่อนหรือหลัง offset ที่ 0 ของ partition 1 ก็ได้
  • ไม่สามารถแก้ไขข้อมูลที่เอาใส่ใน topic แล้ว(Immutability)
  • ข้อมูลจะถูกลบในเวลาที่เราตั้งไว้ (default 604800000 ms หรือ 7 วัน) ไม่ว่ามันจะถูกหยิบไปใช้หรือไม่ เพราะต้องเคลียร์พื้นที่ใน hdd
  • ข้อมูลจะถูกสุ่มจากฝั่ง client โดยการสุ่ม(ขึ้นอยู่กับ implementation ของ client นั้นๆ) message key ในการจัดว่าควรจะอยู่ partition ไหน ในกรณีที่มีมากกว่า 1 partiton ยกเว้นมีการกำหนด message key (ไม่ใช่ id นะครับ)
  • topic มีกี่อันก็ได้ แต่ห้ามซ้ำ ใน topic มี partition กี่อันก็ได้อีกเช่นกัน
  • ตัวนับว่าตัวถัดไปควรใช้เลขอะไร ถูกบันทึกใน ใน topic ที่ชื่อ __consumer_offsets (ใน version ต่ำกว่า 0.9 ตัวนับจะเก็บใน zookeeper) ขอบคุณข้อมูลจาก Pitsanu ด้วยคับ
  • ข้อมูลมากไม่ใช่ปัญหาในการอ่านข้อมูล

สิ่งที่ควรทำความเข้าใจต่อไปถัดจาก Topics และ Partitions คือ Brokers

Brokers

ตัวที่เก็บข้อมูลของ Kafka เนี่ย มันบรรจุอยู่ใน server ซึ่งจะเรียกมันว่า broker
ใน broker 1 ตัวเนี่ย มีตัวเลข id ตัวเองซึ่งไม่ซ้ำระบุอยู่ ตัว broker จะบรรจุข้อมูลในระดับ partition ของแต่ละ topic (ซึ่งถ้าถามว่ามันมีการกำหนดยังไงว่า partition ไหน ควรอยู่ใน cluster ตัวใด มีอธิบายใน stackoverflow หลักๆ เลยคือ มันไม่บอก ขอให้เชื่อมันสิ่งที่มันจัดให้ 5555) และมี broker หลายๆ ตัวประกอบกัน (มีการ distribute) เลยเรียกมันว่า Kafka cluster

ถ้าเรามี cluster (broker หลายตัว แนะนำ 3 ตัวเป็นอย่างต่ำ) เราสามารถเข้าถึง cluster ซึ่งมันจะมีศัพท์เฉพาะเรียกกลุ่ม cluster นี้ว่า Bootstrap servers ขอเพียงต่อเข้าตัวใดตัวหนึ่งได้ เราจะสามารถเข้าถึงข้อมูลใน broker ตัวอื่นใน cluster ได้เหมือนกัน

เพื่อเห็นภาพที่ตรงกัน อยากจะให้ดูตัวอย่างสักนิด

จากตัวอย่าง จะเห็นภาพมากขึ้นว่ามันเก็บข้อมูลในระดับ partition ยังไง หากเราต้องการข้อมูลใน topic 1, partition1 เราไม่จำเป็นต้องต่อตัว broker 2 ก็ได้ เข้าที่ตัว broker 1 ก็จะสามารถเข้าถึงได้เหมือนกัน ตัว Kafka มันรู้เองว่าต้องไปเอาข้อมูลจาก broker ตัวไหน

Topic Replication Factor

เราควรทำความเข้าใจค่านี้ด้วยครับ เป็นค่าที่บอกว่าในแต่ละ topic จะมีการทำสำเนา (replica) partition จากตัวหลัก(leader)ไปสำเนากี่ server หรือ broker (เรียกว่า in-sync replication คือถ้าข้อมูลเข้ามาตัวหลักก็ส่งไปทำสำเนาเพิ่มเลย) ซึ่งควรมีมากกว่า 1 ปกตินิยม 2–3 ครับ และ partition ตัวหลักก็มีได้ 1 ตัว ซึ่งคุณสมบัตินี้เอง เป็นตัวที่ทำให้ Apacha Kafka มีคุณสมบัติ Fault Tolerance (ความทนต่อความเสียหาย) ถ้ามี broker ตัวนึงเน่าไป มันยังสามารถไปอ่านและบันทึกข้อมูลต่อในตัวที่เป็น replica ได้ด้วย โดยการเปลี่ยนตัวสำเนาหรือ replica ให้เป็นตัวหลัก หรือ leader หรือบางคนเรียก master

จากตัวอย่าง สีเหลืองคือ leader สีขาวคือ replica โดยมีตัวเลข replication factor เป็น 2

ถ้าหาก broker ตัวใดตัวนึงตาย ไป เรายังมีอีก 2 ตัวให้ทำงาน แต่ถ้าหากมันตายมากกว่า 1 ตัวก็จบ เพราะฉะนั้น การกำหนดตัวเลข replication factor จึงสำคัญ ยิ่งมากยิ่งปลอดภัย ซึ่งจะทนการเน่าของ server ได้ n-1 ตัว ถ้าสมมติกำหนด replication factor เป็น n แต่ก็มีสิ่งแลกเปลี่ยนคือมันเปลืองพื้นที่ในการเก็บ ก็ต้องประมาณจากความเสี่ยงที่มีเองครับ

แล้วถ้าจะเปลี่ยน replication factor ได้ไหม?? ตอนแรกผมเองก็คิดว่าค่า replication factor นี้เปลี่ยนไม่ได้ แต่จริงๆ มันทำได้ มีวิธีการนี้อยู่

ถ้าหากจะเอาปลอดภัย มี Mirror Maker ให้ใช้อีกครับ ไว้ทำสำเนาไปนอก cluser เลย

Producers

Producer นี่เป็นอะไรที่ไม่ค่อยซับซ้อนมาก หลักๆ ก็คือการเอาข้อมูลเข้าไปอยู่ใน partition ใน topic ที่เรากำหนดไว้ วิธีใช้งานก็ง่ายๆ แค่ต่อเข้า broker ตัวใดตัวนึง แล้ว produce ไป ด้วย topic อันนึง data มันก็จะถูกส่งไปหา partition ในแต่ละ broker เอง ตรงนี้ kafka ทำให้หมดเลย

Message keys

สิ่งที่อยากจะเน้นย้ำคือเรื่องการเรียงลำดับของข้อมูล ถ้าเราไม่กำหนด key ให้มัน ตัว client จะกำหนด key ให้อัตโนมัติ ซึ่งตรงนี้ก็ขึ้นอยู่กับระบบการสุ่มหรือการกระจายของ key ที่กำหนดใน implementation ของฝั่ง client เอง(ไม่เกี่ยวกับฝั่ง broker) ถ้าเป็น sequential ก็จะใกล้เคียงกับการเป็น round-robin ที่จะส่งไปยัง broker ที่ partition ใน topic นั้นๆ อยู่ แต่ถ้ามีการกำหนด key ไว้ หลังจากการ produce ครั้งแรกแล้ว ถ้ามี key เดิมซ้ำกันเข้ามา มันจะวิ่งเข้าไปหา broker ที่เดิมที่ key นั้นเคยเข้าไปอยู่ครั้งแรก

ส่วนเงื่อนไขที่จะพิจารณาว่า message key อันไหน ควรอยู่ที่ partition ไหน อันนี้มันจะเอา key ไป hash แล้วเอาค่ามาทำอะไรบางอย่างซึ่งตรงนี้ มันลึกมากแล้ว ผมเองยังศึกษาไม่ถึง ขอข้ามส่วนนี้ไปก่อน

Acks

ตัวนี้เป็นอีก 1 ตัวที่ควรจะตระหนักไว้ในการทำงานของ producer จะมี 3 ระดับ คือ
- acks=0 producer จะ produce แล้วไม่รอผลตอบของ broker ว่าได้รับข้อมูลหรือยัง
- acks=1 producer จะ produce และรอผลตอบจาก leader partition ว่าได้รับข้อมูลหรือยัง
- acks=all producer จะ produce และรอผลจนข้อมูลถูกบันทึกเข้า replica แล้ว

สรุป ระดับ acks=0 นี่ทำงานไวสุด แต่มันก็ไม่ได้รับประกันได้เลยว่าข้อมูลจะถูกเก็บถึงจริงๆ หรือเปล่า ส่วน acks=1 นี่ เริ่มนานขึ้น เพราะต้องรอตัว leader ตอบกลับ ซึ่ง acks=1 นี่ เป็นค่า default ของ producer ด้วย และระดับ 3 นี่ จะไม่มีข้อมูลหายเลย แต่ก็แลกกับการทำงานที่ช้าขึ้น เหมาะสำหรับข้อมูลสำคัญๆ

Consumers

ก่อนจะเริ่มอ่าน อยากให้ชัดเจนก่อนว่า การอธิบายด้านล่างเป็นพฤติกรรมของ Java client ครับ ถ้าเป็น client ของภาษาอื่น อาจจะทำงานต่างกัน (เพิ่มเติม June 28, 2019)

หน้าที่หลักของ consumer คืออ่านข้อมูลจาก partition เพียงแค่ต่อเข้า broker สักตัว แล้วระบ topic ไป มันก็จะอ่านให้เอง ไม่ว่า partition จะอยู่ที่ broker ไหนก็ตาม เหมือนๆ กับฝั่ง producer เลยครับ

Order

อยากเน้นเรื่องลำดับการอ่านอีกแล้วครับ ตัว consumer จะอ่านข้อมูลตามลำดับใน partition เดียวกัน แต่ถ้าต่าง partition มันจะอ่านแบบขนาน(parallel) เพราะฉะนั้น การออกแบบการเรียงลำดับตัว message key ตั้งแต่ฝั่ง consumer เลย จริงเป็นสิ่งที่สำคัญมาก ถ้าข้อมูลจำเป็นต้องถูกเรียกใช้ตามลำดับ ยกตัวอย่างนะครับ

ถ้าเรามี consumer มาอ่านเนี่ย ข้อมูลที่เราได้ จะไม่ได้เรียง a,b,c,d.. ตามลำดับแบบนี้นะครับ partition0 อาจจะถูกอ่านไวกว่าที่ partition1 ก็ได้ ข้อมูลที่ consume มา อาจจะเป็น b,d,a,c,d…. ก็เป็นไปได้ แต่อยากให้สังเกตอย่างเดียวครับ มันจะเรียงตามลำดับตามใน paritition เดียวกันแน่นอน a จะต้องมาก่อน c และ c จะมาก่อน e ในขณะเดียวกัน b จะมาก่อน d แน่นอน

Consumer Groups

ปัญหาคือ ถ้าหากระบบเรา produce ข้อมูลเข้ามามากๆ ในขณะเดียวกัน consumer ที่เรามีก็น้อยไม่เพียงพอ เราเพิ่ม consumer ได้เลย เพื่อให้มัน consume ได้เร็วขึ้น โดยมันจะ consume แบบขนานกันไป ซึ่งหนึ่งข้อบังคับของ consumer ใน group หนึ่ง ต้องมีจำนวน consumer ไม่เกินจำนวน partition ใน topic ที่ consumer นั้นสนใจ ถ้ามีเกินมา ตัวนั้นจะไม่ได้ทำอะไร

จากตัวอย่าง
- ถ้า group มี consumer 1 ตัว consumer ตัวเดียวนั้นจะอ่านข้อมูลจากทุก partition
- ถ้า group มี consumer 2 ตัว consumer 1 ในสองตัวนั้น จะอ่านข้อมูลจาก 2 partition และอีก 1 ตัวจะอ่านจาก partition ตัวที่เหลือตัวเดียว
- ถ้า group มี consumer 3 ตัว consumer แต่ละตัวก็จะรับผิดชอบแต่ละ partition เลย
- และถ้ามีเกิน 3 ตัว ตัวที่เกินมานั้นจะไม่ได้อ่านอะไรเลย

จากตัวอย่างเดียวกัน จะสังเกตดูได้ว่าจะไม่มี consumer อ่าน partition ที่ซ้ำกันเลย แปลว่าไม่ว่าเราจะเพิ่มหรือลด consumer มันจะไม่อ่านข้อมูลซ้ำกันเด็ดขาด

และความสุดยอดอีกอย่างนึงคือ เราสามารถเพิ่มและลด consumer ได้ตอนไหนก็ได้ มันจะมีการแบ่ง(re-balance)หน้าที่ของ consumer ที่มีให้เองว่าควรไปอ่านที่ partition ไหน ซึ่งตรงนี้เป็นคุณสมบัติ resilient หรือความยืดหยุ่นนั่นเอง

อาจจะสงสัยนะครับ แล้วถ้ามีหลาย group ล่ะ(เพื่อจุดประสงค์การ consume ที่ต่างออกไป เช่นมี serviceใหม่) มันจะเริ่มอ่านจากตรงไหน ใน topic เดียวกัน คำตอบคือ แต่ละ group จะมีตัวนับ(offset) ของมันเอง สมมติมี group แรก กำลังทำงานอยู่ แล้วเราเพิ่ม group ที่ 2 เข้ามา มันก็จะเริ่มอ่านตั้งแต่แรก(ซึ่งเซ็ตได้ว่าจะให้อ่านจากตั้งแต่แรกหรือจากข้อมูลปัจจุบัน) คือตัวนับมันจะแยกกันโดยสิ้นเชิง

Zookeeper

มาถึงอีกหนึ่งพระเอกของเรา Zookeeper
- ทำหน้าที่จัดการ brokers คือรู้ว่า broker ตัวไหน อยู่ที่ไหน ตายอยู่หรือไม่ตาย
- บันทึกว่า topic ไหนมีหรือไม่มี มีกี่ partition ใน topic นี้
- ทำการเลือก leader/replica ของ partition
- ส่งสัญญาณไปหา Kafka ในทุกๆ การเปลี่ยนแปลงที่เกิดขึ้น เช่น มี topic มาใหม่ หรือมี broker ตาย หรือเพิ่มขึ้นมา
- บันทึกว่า producer/consumer แต่ละตัวควรจะเขียนหรืออ่าน data ได้เท่าไหร่
- เก็บ Authorization ว่า user ไหนถูกอนุญาตให้สร้าง topic บ้าง (โดยส่วนตัวผมยังไม่มีโอกาสได้ทดลองตรงส่วนนี้ ลองอ่าน doc ไปก่อนได้ครับ)
- บันทึกว่าแต่ละ consumer group มี consumer กี่ตัว อ่านไปถึง offset ไหนแล้ว
- มีพรรคพวก(quorum)ของมันเอง นิยมให้เป็นจำนวนเลขคี่เช่น มี 3,5,7… ตัวของ จำนวน Zookeeper เพราะมีเรื่อง consensus ในการบันทึกข้อมูลด้วย เช่น ต้องเป็นจำนวนมากกว่าครึ่งนงของ Zookeeper ที่รันอยู่เช็คแล้วว่าถูกบันทึกแล้ว ตัว leader ของ Zookeeper เองจะบันทึกเสร็จแล้วจริงๆ ซึ่งตรงโลกของ Zookeeper นี้ ผมจะขอข้ามไปก่อน(ยังเข้าไม่ถึง)

การรับประกันการส่งข้อความ

ใจจริง topic นี้อยากตั้ง title ให้เป็นภาษาอังกฤษตาม document มัน คือ Message Delivery Semantics นั่งจ้องมัน ละก็เปิด dictionary ละก็ไม่เข้าใจ ต้องไปตามอ่านว่ามันทำอะไรกันแน่

หัวข้อนี้เป็นหัวข้อสุดท้ายละสำหรับโพสนี้ การรับประกันที่ว่าเนี่ย มี 3 สิ่งตาม document คือ

  • At most once → Messages may be lost but are never redelivered.
  • At least once → Messages are never lost but may be redelivered.
  • Exactly once → This is what people actually want, each message is delivered once and only once.

ทำไมต้องมาสนใจของพวกนี้ด้วย?? เพราะตอนเราสร้าง consumer ขึ้นมา เราสามารถ commit ได้ว่าเราอยากจะ commit ตัวนับ(offset) ไว้ล่าสุดตรงไหน ให้ครั้งต่อไป consumer จะกลับมาอ่านต่อ

3 สิ่งนี้จะเป็นสิ่งที่เราจะเจอตอนที่เราทำงานกับ consumer

At most once

ขยายความได้ว่า consumer จะรับ message มาแต่ละอันมากสุดแค่อันละครั้งเดียว
แปลว่าหลังจากที่ consumer อ่าน message มาแล้ว มันจะถือว่า message นั้น ถูกอ่านไปแล้ว และจะนับ offset ที่ตัวล่าสุดเลย(ขึ้นอยู่กับความถี่การบันทึกตัวนับ อธิบายไว้ด้านล่างครับ ขอบคุณ Pitsanu ที่ช่วยชี้แจงตรงนี้ครับ) ฟังดูดี ละปัญหามันคืออะไร

ปัญหามันคือ ถ้าขณะตอนที่อ่านมาแล้ว consumer ตัวนั้นดันเน่าขึ้นมา มันจะไม่กลับไปอ่านต่อตัวที่เพิ่งอ่านมานะ ก็คือจะมีข้อมูลหายตอนส่งไปไปยัง target system นั่นเอง รวมถ้าถึง target system เราเน่าเองด้วย ก็จะไม่ได้รับข้อมูลน้ันเช่นกัน ค่า default ของ consumer เป็นแบบนี้อยู่ คือถูกตั้งค่าการนับ offset ไว้เป็นแบบอัตโนมัติ (enable.auto.commit เป็น true) แต่มันจะนับการ commit ตัว offset ขึ้นอยู่กับช่วงเวลา ซึ่งค่า config คือ auto.commit.interval.ms จะมีค่า default อยู่ที่ 5000 ms

At least once

ขยายความได้ว่า consumer จะรับ message มาแต่ละอัน อย่างน้อยอันละหนึ่งครั้ง
แปลว่าหลังจากที่ consumer อ่าน message มาแล้ว ถ้า consumer เน่า หรือตอนส่งข้อมูลไปหา target system แล้วมีปัญหา มันจะยังสามารถกลับอ่าน message อันเดิมซ้ำๆ จนกว่าจะมีการ commit หรือ บันทึก offset นั่นเอง อันนี้ฟังดูดี เรารับประกันได้ว่า target system เรา จะไม่มีข้อมูลหายแน่นอน

แต่ก็มีปัญหาอีก สมมติว่า consumer กำลังจะบันทึก offset พอดี consumer ดันมาตาย ถ้าปลุกมันมา มันจะไปอ่านที่ตัว message เดิมก่อนบันทึก offset แล้วก็ทำซ้ำ หรือถ้าปัญหาเกิดที่ target system เอง โดย consumer รู้ได้ว่ายังไม่จบกระบวณการที่ target system ก็จะไม่บันทึกค่า offset ซึ่งอาจจะมีการรอจนกว่า target system จะกลับมาปกติ แล้วส่งข้อมูลเดิมไปใหม่ ปัญหาคือ target system จะได้รับข้อมูลซ้ำอันเดิมอีกครั้ง

Exactly once

ขยายความได้ว่า consumer จะรับ message มาแต่ละอันแค่ครั้งเดียวเท่านั้น
จริงๆ อันนี้ฟังดูเหมือนคล้าย at most once มาก แต่ความหมายจริงๆ คือ ทุกกระบวณการ จะทำแค่ครั้งเดียว ทุกอย่างสมบูรณ์แบบถ้า consumer ตายแบบกรณี at least once ตัวข้อมูลจะไม่ถูกส่งซ้ำ ฟังดูตามที่ doc อธิบายไว้คือ นี่คือสิ่งที่ทุกคนต้องการ แต่มันก็มีปัญหาอีก

ปัญหาคือ การจะทำอะไรแบบนี้ได้ มันยากมาก ต้องมีระบบเบื้องหลังควบคุมอย่างรัดกุม เอาง่ายๆ ว่า ทำยากมาก คืออาจจะทำได้นะ แต่จะคุ้มหรือเปล่าที่ต้องมีอะไรๆ มาเยอะแยะ

ซึ่งการนำไปใช้งานจริงๆ นิยมใช้เป็นแบบ at least once มากกว่า แล้วทำ target system ให้มีคุณสมบัติ idempotent เพิ่มมา คือ รับข้อมูลซ้ำๆ ไม่เป็นไร จะตัดทิ้ง จะบันทึกซ้ำ ยังไงก็ได้ แต่ผมชอบแบบบันทึกซ้ำมากกว่า คือให้มันมีการทำ upsert ได้เลย คือ ไม่มี message นั้นก็บันทึกใหม่ ถ้ามีแล้วก็บันทึกซ้ำ

ผมคิดว่าจากที่เล่ามาทั้งหมด น่าจะพอครอบคลุมหลักการต่างๆ ของ Apache Kafka ได้พอสมควร โพสตอนหน้า

จะเป็น tutorial ให้ลองเริ่มลงมือทำจริงๆ

References

--

--

Neng Liangpornrattana
LINE Developers Thailand

A data plumber, basketballer, workout addicted, dog and cat lover