PublishedAt

NodeJS

[Workshop] ทำ Chat Application ด้วย Express + Socket.io และ React.js

[Workshop] ทำ Chat Application ด้วย Express + Socket.io และ React.js

สวัสดีครับ Workshop นี้เป็น workshop ที่ต่อยอดมาจากตัวที่แล้วนะครับ เป็นการทำ Chat Application ด้วยการใช้ socket.io และ React.js

ซีรีย์ทำ Chap Application

วันนี้เราจะมาทำ Chat ที่มันดีขึ้นกว่า ครั้งก่อนครับ แต่ feature หลักๆ ก็ยังมีความคล้ายกันครับ เพียงแค่เปลี่ยนมาเป็น React และก็วิธีการแบ่ง component รับส่ง props ครับ โดยสิ่งที่จะมีเพิ่มจากตอนแรก คือ:

  1. ใช้ React - ฝั่ง Client เปลี่ยนจาก HTML ที่ render ที่เดียวกับ Express มาเป็น React.js ฉะนั้น ก็เลยเหมือนมี 2 เว็บ คือ 1. ฝั่ง server และ 2. ฝั่ง client side.
  2. แสดงรายชื่อ คนที่ Online
  3. เวลาที่มีใครกำลังพิมพ์ในห้องแชต ให้แสดงว่า มีคนกำลังพิมพ์อยู่…
  4. Scroll ไปที่แชตล่าสุด (เวลาที่แชตมันยาวๆ เวลามีคนพิมพ์มาใหม่ มันจะ scroll ไปล่างสุด)

ระดับความยาก: ⭐️⭐️

หน้าตาเว็บที่ได้ เป็น Single Page หน้าเดียว แบบนี้ครับ (ด้านซ้าย เป็นรายชื่อห้องแชต ยังไม่ได้รวมอยู่ใน Workshop นี้นะครับ แต่ใส่มาไว้ก่อน)

Chat App React

Step 1 - เริ่มต้นสร้างโปรเจ็ค

เหมือนเดิมครับ เริ่มต้น เราสร้างโปรเจ็ค โดยแบ่งเป็น สองส่วน

  1. สร้าง folder server ขึ้นมา เอาไว้เป็นโค๊ดส่วน server
  2. สร้าง frontend ขึ้นมา โดยใช้ Vite

สร้างโปรเจ็ค React ด้วย Vite

Terminal window
pnpm create vite@latest

จากนั้น ตั้งชื่อโปรเจ็ค, เลือก JavaScript และทำการ install dependencies

Terminal window
Project name: your-project-name
Select a framework: React
Select a variant: JavaScript

ทดลอง start server ขึ้นมาก่อน

Terminal window
pnpm dev

ทีนี้ ตัว default ของ React เราจะไม่ใช้ครับ ทำการลบข้อมูลที่ไฟล์ App.jsx ให้หมด เหลือเพียงแค่นี้:

App.jsx
import './App.css'
function App() {
return (
<>
<h2>Chat App with React</h2>
</>
)
}
export default App

Step 2 - Web Socket ฝั่ง Server

สำหรับฝั่ง Server สามารถใช้โค๊ดเดิมของ Workshop ก่อนหน้าได้ โดยเราทำการสร้างไฟล์ index.js ขึ้นมาภายในโฟลเดอร์ server

index.js
import path from 'path'
import { fileURLToPath } from 'url'
import express from 'express'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
import { createServer } from 'node:http'
import { Server } from 'socket.io'
const app = express()
// 1. สร้าง `server` ด้วย `app` โดยใช้ `createServer` จาก `node:http`
const server = createServer(app)
// 2. สร้าง `io` โดยใช้ `new Server` จาก `socket.io`
const io = new Server(server)
io.on('connection', (socket) => {
console.log('a user connected')
socket.on('chat:message', (msg) => {
console.log('message: ' + JSON.stringify(msg))
io.emit('chat:message', msg)
})
})
const APP_PORT = 5555
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname + '/index.html'))
})
// 4. เปลี่ยน `app.listen` เป็น `server.listen`
server.listen(APP_PORT, () => {
console.log(`App running on port ${APP_PORT}`)
})

โค๊ด : https://github.com/Devahoy/ws-chat-app-express-socketio/blob/main/index.js

และเนื่องจากฝั่ง Server เราจะให้มันมีแค่ socket ฉะนั้น ก็ไม่จำเป็นต้อง server index.html ครับ ลบโค๊ดตรงนี้ได้เลย สุดท้าย index.js เราจะเหลือแค่นี้

index.js
import express from 'express'
import { createServer } from 'node:http'
import { Server } from 'socket.io'
const app = express()
const server = createServer(app)
const io = new Server(server)
io.on('connection', (socket) => {
console.log('a user connected')
socket.on('chat:message', (msg) => {
console.log('message: ' + JSON.stringify(msg))
io.emit('chat:message', msg)
})
})
const APP_PORT = 5555
server.listen(APP_PORT, () => {
console.log(`App running on port ${APP_PORT}`)
})

ส่วนไฟล์ package.json ก็ใช้แบบนี้

{
"name": "chat-app-express-socketio",
"module": "index.ts",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2"
}
}

ติดตั้ง socket.io และ express เพื่อทดสอบ run server

Terminal window
pnpm install

จากนั้น start api server

Terminal window
node index.js

Step 3 - Socket.io ฝั่ง client

กลับมาที่ฝั่ง React ทำการติดตั้ง socket.io-client ลงไป

Terminal window
pnpm install socket.io-client

จากนั้นสร้างไฟล์ socket.js ขึ้นมา เอาไว้ที่โฟลเดอร์ libs (สร้างใหม่)

socket.js
import { io } from 'socket.io-client'
export const socket = io('http://localhost:5555')

จากโค๊ดด้านบน จะเห็นได้ว่า socket ทำการ connect ไปที่ localhost:5555 นั่นก็คือ server ที่เราใช้รัน node.js อยู่นั่นเอง

จากนั้นที่ไฟล์ App.jsx ให้ทำการ import socket มา และก็ลองเช็ค connection แบบนี้

App.jsx
import { useEffect } from 'react'
import { socket } from './libs/socket'
import './App.css'
function App() {
useEffect(() => {
socket.on('connection', () => console.log('socket connected')
}, [])
return (
<>
<h2>Chat App with React</h2>
</>
)
}
export default App

ลอง start react ขึ้นมา (คนละ port กับ server นะครับ ตัวนี้คือ localhost:5173)

ตอนนี้ เรามี 2 server คือ

  1. http://localhost:5555 - ฝั่ง server เป็น express + socket.io
  2. http://localhost:5173 - ฝั่ง client เป็น React + socket.io-client

เมื่อเปิด browser http://localhost:5173 แล้วดูที่ debug console จะเห็นว่ามันติด CORS ไม่สามารถต่อ socket.io ได้ เราต้องไปตั้งค่าฝั่ง server ให้รับ domain ของฝั่ง client ด้วย (โดยปกติแล้ว api หรือ socket ทั่วๆไป จะไม่ยอมให้ request ข้าม server กันได้ จาก client)

ที่ไฟล์ server/index.js เพิ่ม config cors ลงไปแบบนี้

server/index.js
const io = new Server(server, {
cors: {
origin: 'http://localhost:5173',
},
})

ลอง stop/start server ใหม่อีกครั้ง

Step 4 - React Component

ในตัวอย่างนี้ เราจะแบ่ง Component ออกเป็นหลักๆ 4 ตัวครับคือ

  1. <Chatbox /> - เป็นตัวเอาไว้แสดง chat message
  2. <ChatSidebar /> - เป็นส่วนด้านข้างของ chat เอาไว้แสดงรายชื่อห้องแชต
  3. <ChatFooter /> - ส่วนนี้เป็นส่วนที่เอาไว้พิมพ์ข้อความ
  4. <FriendList /> - ตรงนี้เป็นด้านขวามือ แสดงคน online อยู่

Chat React

มาวางโครงสร้าง component กันครับ เริ่มต้นสร้างโฟลเดอร์ components ขึ้นมา มี 4 ตัวดังนี้

ไฟล์ Chatbox.jsx

Chatbox.jsx
const ChatBox = () => {
return (
<>
<div id="chat-box">
</div>
</>
)
}
export default ChatBox

ไฟล์ ChatSidebar.jsx

ChatSidebar.jsx
const ChatSidebar = () => {
// todo, add a list of channels
return (
<aside id="chat-sidebar">
<h2>Chat App with socket.io + React</h2>
<a href="#welcome"># Welcome</a>
<a href="#general"># General - พูดคุยทั่วไป</a>
<a href="#update"># Update - อัพเดทข้อมูลข่าวสาร</a>
<a href="#react"># React - พูดคุย React.js</a>
</aside>
)
}
export default ChatSidebar

ไฟล์ FriendList.jsx

FriendList.jsx
const FriendList = () => {
return (
<div id="chat-friend-list">
<h3>Friends</h3>
</div>
)
}
export default FriendList

ไฟล์ ChatFooter.jsx

const ChatFooter = () => {
return (
<h2>Chat Footer</h2>
)
}
export default ChatFooter

ปรับแต่ง CSS โดยลบไฟล์ default ที่มากับ Vite ตัว index.css เหลือแค่นี้ (เพิ่ม custom font)

@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+Thai:wght@300,500&display=swap');
:root {
font-family: 'Noto Sans Thai', Inter, system-ui, Avenir, Helvetica, Arial,
sans-serif;
line-height: 1.5;
font-weight: 400;
font-size: 16px;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

ส่วนไฟล์ App.css ลบหมดเลยครับ เราจะมาปรับ css ที่ไฟล์นี้กัน โดยเริ่มจาก กำหนด container, sidebar และ chat box

.chat-container {
display: flex;
flex-grow: 1;
height: 100vh;
}
#chat-sidebar {
background: #252525;
width: 280px;
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 0.5rem;
}
#chat-box {
background: #ddd;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
#chat-footer {
display: flex;
gap: 0.5rem;
background: #d4d4d4;
padding: 1.5rem;
}
#chat-friend-list {
background: #252525;
padding: 1rem;
width: 240px;
overflow-y: scroll;
color: #ccc;
}

หลักๆ ก็จะมีประมาณนี้ เพื่อให้ได้ layout ตามที่วางแผนไว้ คือ

  • ตัว container คือสูง 100vh ตามขนาดจอเลย
  • Sidebar กำหนด width ไว้เลย 280px
  • ส่วน chat ก็กว้างเต็มจอ แต่จะมีเหลือไว้ สำหรับ friendlist ด้านขวา 240px

ส่วน CSS ก็ขออธิบายคร่าวๆ เพียงแค่นี้นะครับ ที่เหลือ ก็จะเป็นการปรับ spacing, padding margin เล็กๆ น้อยๆ

สุดท้าย ไฟล์ App.jsx เอา component ที่เราทำมารวมกัน เป็นแบบนี้

App.jsx
import { socket } from './libs/socket'
import ChatSidebar from './components/ChatSidebar'
import ChatBox from './components/ChatBox'
import FriendList from './components/FriendList'
import './App.css'
function App() {
return (
<div className="chat-container">
<ChatSidebar />
<ChatBox />
<FriendList />
</div>
)
}
export default App

ไฟล์ App.css ของบทความนี้ สามารถดูได้ที่นี่นะครับ ไฟล์ App.css

Step 5 - emit message ไป server

ขั้นตอนนี้ เราจะทำการ emit ข้อความที่เราจะส่ง ไปที่ server คล้ายๆ กับ Workshop ก่อนหน้านี้ครับ ฉะนั้น ตรงส่วนนี้ เราก็ใช้โค๊ดเดิมได้ ตรงนี้แก้ที่ไฟล์ ChatFooter.jsx ครับ

มี form ดังนี้

ChatFooter.jsx
import { useState } from 'react'
import { socket } from '../libs/socket'
const ChatFooter = () => {
const [name, setName] = useState(getName())
const [message, setMessage] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
}
function getName() {
// get name from date timestamp
const date = new Date()
return 'User-' + date.getTime()
}
const handleChange = (e) => {
const { name, value } = e.target
if (name === 'name') {
setName(value)
} else if (name === 'message') {
setMessage(value)
}
}
return (
<form className="chat-footer" onSubmit={handleSubmit}>
<input type="text" name="name" value={name} onChange={handleChange} />
<input
id="message"
type="text"
name="message"
value={message}
onChange={handleChange}
/>
<button type="submit">Send</button>
</form>
)
}
export default ChatFooter

โดยที่ผมมี state 2 ตัวคือ เอาไว้เก็บ name และ message ที่จะส่งครับ ตัวอย่างนี้ใช้แบบ controlled form นะครับ เวลาที่ user กรอก input ก็จะเข้า onChange และก็เวลากดส่ง ก็จะเข้า handleSubmit

ทีนี้ส่วน handleSubmit เวลาที่เราจะ emit ไปที่ server เราต้องใช้ socket ก็ทำการ import มาจาก libs/socket ได้เลย

import { socket } from '../libs/socket'
const handleSubmit = (e) => {
e.preventDefault()
if (!message) return
const payload = {
username: name,
message,
time: new Date().toLocaleDateString(),
}
socket.emit('chat:message', payload)
// เคลียร์ค่าหลังจาก emit
setMessage('')
}

ทีนี้ เราก็สามารถ emit ข้อความไปที่ server ได้แล้ว

Step 6 - รับ event chat:message

ต่อมา เราทำการดักรอ event chat:message ส่วนนี้ เราจะทำที่ไฟล์ App.jsx โดยทำที่ส่วน useEffect() แบบนี้

App.jsx
import { socket } from './libs/socket'
const handleNewMessage = (data) => {
console.log('received message : ', data)
}
useEffect(() => {
socket.on('chat:message', handleNewMessage)
}, [])

ทีนี้เมื่อเรารู้ว่ารับ event message ได้แล้ว ก็ทำการเซฟ message ที่ได้ ไว้ใน state

const [messages, setMessages] = useState([])
const handleNewMessage = (data) => {
console.log('received message : ', data)
setMessages((message) => [...messages, data])
}

จากนั้นส่ง messages ไปเป็น props ไปที่ Chatbox ครับ

<Chatbox messages={messages} />

ที่ไฟล์ ChatBox.jsx ให้เราทำการรับค่า props messages พร้อมทั้งทำการ render message แบบนี้

import ChatFooter from './ChatFooter'
/* eslint-disable react/prop-types */
const ChatBox = ({ messages }) => {
return (
<>
<div id="chat-box">
<div className="chat-box-messages">
{messages.map((message) => (
<div className="chat-box-message" key={message.id}>
<p className="chat-box-meta">
{message.username} <span>{message.time}</span>
</p>
<p className="chat-box-text">{message.message}</p>
</div>
))}
</div>
<ChatFooter />
</div>
</>
)
}
export default ChatBox

ทดสอบ ส่งข้อความ และดูว่าได้รับข้อความแสดงถูกต้อง ทีนี้เมื่อถึงตรงนี้ ตัว Chat App เราก็ทำงานได้ถูกต้อง รับ ส่ง ข้อมูลได้ แสดงผลได้ ต่อไปเป็น Optional feature เพิ่มเติม ที่ทำให้แอพดูดีขึ้น

Step 7 - Auto Scroll

ตอนนี้จะเห็นว่าเราสามารถส่งแชต และแสดงแชตได้แล้ว แต่เวลาที่แชตมันยาวๆ เนี่ย เราต้องมาเลื่อน scroll ลงมาเอง และไม่รู้ว่ามีข้อความใหม่หรือไม่ ในส่วน UX มันก็ยังไม่ค่อยดี

ทีนี้ เราจะมาทำ auto scroll คือเวลาที่มีแชตใหม่เกิดขึ้น มันจะเด้งไปแชตล่าสุดทันที ส่วนนี้ให้เราแก้ไขไฟล์ App.jsx และเพิ่มตรงนี้ลงไป

const lastMessageRef = useRef(null)
useEffect(() => {
lastMessageRef.current?.scrollIntoView({
behavior: 'smooth',
})
}, [messages])

เราใช้ useRef เพื่อจะกำหนด ให้ scroll to ไปที่ element ที่เรา ref ไว้ ในทีนี้คือ จะส่งไปที่ ChatBox ผ่าน props แบบนี้ครับ

<ChatBox messages={messages} lastMessageRef={lastMessageRef} />

ทีนี้ส่วน ChatBox ก็มารับ props เพิ่มนิดหน่อย

const ChatBox = ({ messages, lastMessageRef }) => {
...
...
<div ref={lastMessageRef}></div>
}

ไฟล์สุดท้ายของ ChatBox.jsx จะเป็นแบบนี้

Chatbox.jsx
import ChatFooter from './ChatFooter'
/* eslint-disable react/prop-types */
const ChatBox = ({ messages, lastMessageRef }) => {
return (
<>
<div id="chat-box">
<div className="chat-box-messages">
{messages.map((message) => (
<div className="chat-box-message" key={message.id}>
<p className="chat-box-meta">
{message.username} <span>{message.time}</span>
</p>
<p className="chat-box-text">{message.message}</p>
<div ref={lastMessageRef}></div>
</div>
))}
</div>
<ChatFooter />
</div>
</>
)
}
export default ChatBox

ทดลอง restart server และทดลองแชตใหม่ สังเกต เวลาข้อความเยอะๆ และมีข้อความใหม่มา มันจะ auto scroll ให้เราเรียบร้อย

Step 8 - Friend list

ส่วนนี้ จริงๆ ไม่ใช่ friend เนาะ แต่ว่ามันคือส่วนที่เอาไว้บอกว่ามีใคร online อยู่ (ก็คือเช็คจาก connection นี่แหละ)

โดย event นี้ จะใช้ชื่อ chat:room เพื่อรอรับ รายชื่อ users ที่ online อยู่ ส่วนฝั่ง server ก็แค่ emit มา ตอนที่มี connection เข้ามานั่นเอง

ที่ฝั่ง server แก้ไข server/index.js โดย emit chat:room มา เวลาที่มีคน connection:

let users = []
io.on('connection', (socket) => {
console.log('a user connected')
const index = users.findIndex((user) => user.id === socket.id)
if (index === -1) {
users.push({
id: socket.id,
name: socket.id,
status: 'online',
})
}
io.emit('chat:room', {
type: 'join',
message: `user ${socket.id} connected`,
users,
})
})

ตัวอย่างนี้ ฝั่ง server ไม่ได้เซฟหรือใช้ข้อมูล db จริงๆ นะครับ เป็นแค่การจำลองการ เพิ่ม ลบ ข้อมูลใน array เฉยๆ

กลับมาที่ฝั่ง client ตรงส่วน App.jsx เราก็รับ event เพิ่มเข้าไป ต่อจาก chat:message

const handleRoomConnection = (data) => {
}
useEffect(() => {
socket.on('chat:message', handleNewMessage)
socket.on('chat:room', handleRoomConnection)
}, [])

เราจะสร้าง state friends มาไว้เก็บค่า users จาก event chat:room เพื่อส่งไปเป็น props ไปที่ <FriendList /> ครับ

const [friends, setFriends] = useState([])
const handleRoomConnection = (data) => {
setFriends(data.users)
}
// render
<FriendList friends={friends} />

สุดท้ายส่วน FriendList.jsx ก็ implement และรับค่า props มาแบบนี้

/* eslint-disable react/prop-types */
const FriendList = ({ friends }) => {
return (
<div id="chat-friend-list">
<h3>Friends</h3>
{friends.map((friend) => (
<p key={friend.id}>
{friend.name} <span className={`status-${friend.status}`}></span>
</p>
))}
</div>
)
}
export default FriendList

Step 9 - แสดงคำว่า มีคนกำลังพิมพ์อยู่

ต่อมาส่วนสุดท้ายละครับ คือทำส่วน typing… หรือ มีคนกำลังพิมพ์อยู่ โดย event นี้ เราจะตั้งชื่อให้มันว่า chat:typing เราจะส่งไปบอก server ก็ตอนที่เรา กำลังพิมพ์ข้อความอยู่นั่นเอง

ที่ไฟล์ ChatFooter.jsx ตรงส่วน handleChange ถ้าเป็น message เราจะให้มัน emit typing ไปด้วย แบบนี้

const handleChange = (e) => {
const { name, value } = e.target
if (name === 'name') {
setName(value)
} else if (name === 'message') {
setMessage(value)
socket.emit('chat:typing', { isTyping: true })
}
}

ที่ฝั่ง Server เราก็ทำการรับ event และก็ broadcast กลับไปหา client ทุกคน ยกเว้นคนส่ง

socket.on('chat:typing', (msg) => {
console.log('typing: ' + JSON.stringify(msg))
// ส่งข้อความไปหา client ทุกคน ยกเว้นตัวผู้ส่ง (sender)
socket.broadcast.emit('chat:typing', msg)
})

ทีนี้ จังหวะที่เรา emit typing = true ไว้ แต่เวลาที่เราส่ง chat ไปแล้วเนี่ย มันจะขึ้น มีคนกำลังพิมพ์อยู่ เพราะว่า isTyping มัน true ตลอด เราอาจจะทำได้ 2 แบบคือ

  1. ฝั่ง frontend เก็บ state isTyping แล้ว set false ตอน submit form
  2. ฝั่ง server ให้ส่ง emit chat:typing false มาหลังจาก emit message

สุดท้าย ไฟล์ ChatFooter.jsx ที่ได้ก็จะเป็นแบบนี้

ChatFooter.jsx
import { useEffect, useState } from 'react'
import { socket } from '../libs/socket'
const ChatFooter = () => {
const [name, setName] = useState(getName())
const [message, setMessage] = useState('')
const [isTyping, setIsTyping] = useState(false)
useEffect(() => {
socket.on('chat:typing', (data) => {
setIsTyping(data.isTyping)
})
}, [])
const handleSubmit = (e) => {
e.preventDefault()
if (!message) return
const payload = {
username: name,
message,
time: new Date().toLocaleDateString(),
}
socket.emit('chat:message', payload)
setMessage('')
}
function getName() {
// get name from date timestamp
const date = new Date()
return 'User-' + date.getTime()
}
const handleChange = (e) => {
const { name, value } = e.target
if (name === 'name') {
setName(value)
} else if (name === 'message') {
setMessage(value)
const _isTyping = value !== ''
socket.emit('chat:typing', { username: name, isTyping: _isTyping })
}
}
return (
<>
{isTyping && (
<span style={{ marginLeft: '1.5rem' }}>มีคนกำลังพิมพ์อยู่...</span>
)}
<form className="chat-footer" onSubmit={handleSubmit}>
<input type="text" name="name" value={name} onChange={handleChange} />
<input
id="message"
type="text"
name="message"
value={message}
onChange={handleChange}
/>
<button type="submit">Send</button>
</form>
</>
)
}
export default ChatFooter

สรุป

จบไปแล้วครับ สำหรับ Workshop ที่สอง สำหรับการทำ Chat Application ทีแรกตั้งใจไว้ คิดว่าไม่น่าจะยาวมาก เนื่องจากมันต่อยอดมาจากตัว Workshop แรก ที่เราเข้าใจการรับ ส่ง event กันแล้ว แต่พอทำจริงๆ มันมีหลายๆ ส่วนที่ต้องอธิบายเพิ่ม และก็ใส่พวก optional ที่มีแล้วทำให้ chat มันดีขึ้น เช่น auto scroll, แสดงมีคนกำลังพิมพ์ แสดงชื่อคน connection เป็นต้น

ก็หวังว่าเพื่อนๆ จะได้ไอเดีย ไปต่อยอด หรือไปลองปรับแต่ง เรียนรู้เพิ่มเติมกันดูนะครับ หากติดปัญหาตรงไหน ก็สอบถามได้ตลอดครับ

ตัวอย่าง Source Code เข้าไปดูใน Github ได้จาก link ด้านล่างเลยครับ

Happy Coding ❤️

Authors
avatar

Chai Phonbopit

เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust

Related Posts