# 4/26 大一部課 - [x] Websocket - [x] graphql - [x] 期末進度:分配工作 ## WebSocket credits: https://hackmd.io/@Heidi-Liu/javascript-websocket 我們使用 Restful API (HTTPS)時,與server建立連線,這種連線是一次性的。 當client滿足需求時(server發送了200 OK的response),這段通訊隨即宣告結束。 WebSocket 是 HTML5 提供的一種網路傳輸協定,是瀏覽器(Client)與伺服器(Server)交換資料的方式之一。 與我們較為熟知的 HTTP 或 HTTPS 協定,同樣位於 OSI 模型的應用層,且基於傳輸層的 TCP 協定。其最大不同在於,WebSocket 協定只需連線一次,就能保持雙向溝通,不需重複發送 Request,因此回應更即時,進而提升 Web 通訊速度。 重點是:Server也能主動發送request! 實際範例:訊息推播、即時聊天室、共同編輯等功能 ### Start!! 一般的Websocket 請求網址: ``` ws://example.com ``` 加上TLS/SSL: ``` wss://example.com ``` 我們先建立一個websocket server: ```bash # 建立一個專案 npm init -y # 下載所需要的套件 npm install express npm install ws ``` 建立一個`server.js`的檔案,將下列程式碼貼上去: ```javascript= // import library const express = require('express') const ServerSocket = require('ws').Server // 引用 Server // 指定一個 port const PORT = 8000 // 建立 express 物件並用來監聽 8000 port const app = express(); const server = app.listen(PORT, () => console.log(`[Server] Listening on http://localhost:${PORT}`) ); // 建立實體,透過 ServerSocket 開啟 WebSocket 的服務 const wss = new ServerSocket({ server }) // Connection opened wss.on('connection', ws => { console.log('[Client connected]') // Connection closed ws.on('close', () => { console.log('Close connected') }) }) ``` 在`package.json`底下做以下修改: ```javascript= "scripts": { "server": "node server.js" }, ``` (如果要加nodemon 可以自己加) ```bash npm install --save-dev nodemon ``` ```javascript= "scripts": { "server": "nodemon server.js" }, ``` 在terminal打上 `npm run server` 就跑起來了! 建好server之後,我們來實作client建立連線: ``` . |-- server.js |-- public |-- index.html |-- index.js ``` In `index.html`: ```html= <html> <head> </head> <body> <!-- Connect or Disconnect WebSocket Server --> <button id="connect">Connect</button> <button id="disconnect">Disconnect</button> <!-- Send Message to Server --> <div> Message: <input type="text" id="sendMsg" ><button id="sendBtn">Send</button> </div> <!-- Import index.js after UI rendered --> <script src='./index.js'></script> </body> </html> ``` In `index.js`: ```javascript= var ws // 監聽 click 事件 document.querySelector('#connect')?.addEventListener('click', (e) => { console.log('[click connect]') connect() }) document.querySelector('#disconnect')?.addEventListener('click', (e) => { console.log('[click disconnect]') disconnect() }) document.querySelector('#sendBtn')?.addEventListener('click', (e) => { const msg = document.querySelector('#sendMsg') sendMessage(msg?.value) }) function connect() { // Create WebSocket connection ws = new WebSocket('ws://localhost:8000') // 在開啟連線時執行 ws.onopen = () => console.log('[open connection]') } function disconnect() { ws.close() // 在關閉連線時執行 ws.onclose = () => console.log('[close connection]') } ``` Serve them on the server: In `server.js`: ```javascript= app.use(express.static("public")); ``` Server 端分別能使用 `send` 發送訊息,以及透過監聽 `message` 事件接收來自 Client 的訊息: ```javascript= // Connection opened wss.on('connection', ws => { console.log('[Client connected]') // Listen for messages from client ws.on('message', data => { console.log('[Message from client]: ', data) // Send message to client ws.send('[Get message from server]') }) // ... }) ``` 同樣的,Client 端也能使用 `send` 送出訊息,以及透過 `onmessage` 接收 Server 端的訊息: ```javascript= // 監聽 click 事件 document.querySelector('#sendBtn')?.addEventListener('click', (e) => { const msg = document.querySelector('#sendMsg') sendMessage(msg?.value) }) // Listen for messages from Server function sendMessage(msg) { // Send messages to Server ws.send(msg) // Listen for messages from Server ws.onmessage = event => console.log('[send message]', event) } ``` ### 實做一個聊天室! 先前提到 WebSocket 常應用於即時聊天室等功能,也就是實現 Server 同時與多個 Client 連線。那該如何在 ClientA 傳送訊息給 Server 的同時,讓 ClientB 也接收到來自 Server 回傳的訊息呢? 這時就要仰賴「廣播功能」,首先透過 `ws` 提供的方法 `clients` 取得目前所有連線中的 Clients 資訊,再使用 forEach 迴圈送出訊息給每個 Client: ```javascript= // Connection opened wss.on('connection', ws => { console.log('[Client connected]') // Listen for messages from client ws.on('message', data => { console.log('[Message from client]: ', data) // Get clients who connected let clients = wss.clients // Use loop for sending messages to each client clients.forEach(client => { client.send('[Broadcast][Get message from server]') }) }) // ... }) ``` 如何區分各個 Client? ```javascript= ws.id = req.headers['sec-websocket-key'].substring(0, 8) ws.send(`[Client ${ws.id} is connected!]`) ``` 或是用sequence number 也可以,只要不重複的都是OK的。 最後的程式碼如下: `server.js` ```javascript= const express = require('express') const ServerSocket = require('ws').Server // 引用 Server const PORT = 8000 // 建立 express 物件並用來監聽 8000 port const app = express(); app.use(express.static("public")); const server = app.listen(PORT, () => console.log(`[Server] Listening on http://localhost:${PORT}`) ); // 建立實體,透過 ServerSocket 開啟 WebSocket 的服務 const wss = new ServerSocket({ server }) // Connection opened wss.on('connection', (ws, req) => { ws.id = req.headers['sec-websocket-key'].substring(0, 8) ws.send(`[Client ${ws.id} is connected!]`) // Listen for messages from client ws.on('message', data => { console.log('[Message from client] data: ', data.toString()) // Get clients who has connected let clients = wss.clients // Use loop for sending messages to each client clients.forEach(client => { client.send(`${ws.id}: ` + data) }) }) // Connection closed ws.on('close', () => { console.log('[Close connected]') }) }) ``` `public/index.js` ```javascript= var ws // 監聽 click 事件 document.querySelector('#connect')?.addEventListener('click', (e) => { connect() }) document.querySelector('#disconnect')?.addEventListener('click', (e) => { disconnect() }) document.querySelector('#sendBtn')?.addEventListener('click', (e) => { const msg = document.querySelector('#sendMsg') sendMessage(msg?.value) }) function connect() { // Create WebSocket connection ws = new WebSocket('ws://localhost:8000') // 在開啟連線時執行 ws.onopen = () => { console.log('[open connection]') // Listen for messages from Server ws.onmessage = event => { console.log(`[Message from server]:\n %c${event.data}` , 'color: blue') } } } function sendMessage(msg) { // Send messages to Server ws.send(msg) } function disconnect() { ws.close() // 在關閉連線時執行 ws.onclose = () => console.log('[close connection]') } ``` ## Graphql Graphql 是一個 query language,具體來說,使用者會根據自己的需求篩選出適合的資料,而使用的就是query language。 回想axios+database的作法:假設使用者要尋找database的其中一個使用者,會怎麼做呢? 與Restful API的主要差異: - Restful API 使用 HTTP協定,不失一般性假設query有多種可能,那麼最簡單的實作方式就是將整個資料一起回傳,再從前端篩選出各自需要的資料。 - Graphql 同樣使用大眾的傳輸協定,不過透過query language,只須回傳使用者指定的資料,如此可以減少data flow,進而提升效能。 基礎格式: ```graphql= type Query { me: User } type User { id: ID name: String } ``` Query 方式: ```graphql= { me { name } } ``` 回傳結果: ```json= { "me": { "name": "Luke Skywalker" } } ``` ### Let's create a simple GraphQL server! 我們使用 ApolloGraphQL 來實作。 官網:https://www.apollographql.com/ Create a new project ```bash= mkdir graphql-server-example cd graphql-server-example npm init --yes && npm pkg set type="module" npm install @apollo/server graphql touch index.js ``` In `package.json`: ```javascript= { // ...etc. "type": "module", "scripts": { "start": "nodemon index.js" } // other dependencies } ``` 定義 Schema :::info Every GraphQL server (including Apollo Server) uses a schema to define the *structure* of data that clients can query. ::: In `index.js`: ```javascript= import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; // A schema is a collection of type definitions (hence "typeDefs") // that together define the "shape" of queries that are executed against // your data. const typeDefs = `#graphql # Comments in GraphQL strings (such as this one) start with the hash (#) symbol. # This "Book" type defines the queryable fields for every book in our data source. type Book { title: String author: String } # The "Query" type is special: it lists all of the available queries that # clients can execute, along with the return type for each. In this # case, the "books" query returns an array of zero or more Books (defined above). type Query { books: [Book] } `; ``` 創建一個psuedo data: ```javascript= const books = [ { title: 'The Awakening', author: 'Kate Chopin', }, { title: 'City of Glass', author: 'Paul Auster', }, ]; ``` 定義 Resolver: :::info Resolvers tell Apollo Server how to fetch the data associated with a particular type. ::: ```javascript= const resolvers = { Query: { books: () => books, }, }; ``` 最後,創建一個instance來啟動graphql server: ```javascript= // The ApolloServer constructor requires two parameters: your schema // definition and your set of resolvers. const server = new ApolloServer({ typeDefs, resolvers, }); // Passing an ApolloServer instance to the `startStandaloneServer` function: // 1. creates an Express app // 2. installs your ApolloServer instance as middleware // 3. prepares your app to handle incoming requests const { url } = await startStandaloneServer(server, { listen: { port: 4000 }, }); console.log(`🚀 Server ready at: ${url}`); ``` 執行 `npm start`,就可以看到graphql server啟動了! 打開`http://localhost:4000`,就能進到 Apollo sandbox。 如何使用 Apollo sandbox: https://www.apollographql.com/docs/apollo-server/getting-started/#step-8-execute-your-first-query ### Discussion for future works [CourseMap](https://github.com/NTUEEInfoDep/NTUEECourseMap) 新版:https://hackmd.io/zpak6bIvQtaHMJjTVjgAsg 產學部實習地圖 [系學會網站](https://eesa.ntuee.org/) (目前本地跑不動) 大家有甚麼想法,可以打在下面!! coursemap: 實習地圖: 系學會網站: ### 使用 coursemap 1. 把 [Coursemap](https://github.com/NTUEEInfoDep/NTUEECourseMap) 的 repo clone 下來 (HTTPS 也可以,用SSH需要設定key,請參照 [Generate a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)) ![image](https://hackmd.io/_uploads/HyGguM9-C.png) ```bash cd your-target-directory # git clone [put the copied url here] git clone https://github.com/NTUEEInfoDep/NTUEECourseMap.git ``` 2. 進入coursemap 資料夾裡,安裝專案需要的套件 ```bash cd NTUEECourseMap # yarn 的速度較 npm 快不少,因此使用 yarn 安裝跟執行 # 使用 yarn yarn install # 啟動 coursemap (下列兩種都可以) yarn run dev # 或 yarn start ``` 接著,前往 http://localhost:8000/ ,看到畫面就成功囉! ### 使用系學會網站 目前還有一些設定沒有完成,之後有弄好再更新 (demo 連結在上方)