Kliensoldali webprogramozás

Szerver kommunikáció.
GraphQL.
Websockets.

Horváth Győző
Egyetemi docens
1117 Budapest, Pázmány Péter sétány 1/c., 2.408-as szoba
Tel: (1) 372-2500/8469
horvath.gyozo@inf.elte.hu

Ismétlés

React

  • Komponensek
  • state, props
  • Data down, action up
  • Kompozíció
  • Hooks

Állapottér

  • useState
  • Context
  • Redux
    • egyirányú adatfolyam
    • tiszta függvények
    • immutábilitás

Redux

Middleware

const middleware1 = store => next => action => {
  return next(action)
}

Thunk middleware

Függvény akciók

npm install --save redux-thunk
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

const store = createStore(rootReducer, applyMiddleware(thunk));
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    return next(action);
  };
}

Aszinkron akciók

// szinkron
const fetchItemsRequest = () => ({
  type: FETCH_ITEMS_REQUEST
})
// aszinkron
const getAll = () => async dispatch => {
  dispatch(fetchItemsRequest())
  const items = await api.getItems()
  dispatch(fetchItemsSuccess(items))
}
// aszinkron
const getAll = () => dispatch => {
  dispatch(fetchItemsRequest())
  api.getItems().then(items => dispatch(fetchItemsSuccess(items)))
}

Állapotkezelő könyvtárak

  • Redux
  • MobX
  • MobX-State-Tree
  • XState

Kommunikációs formák

Kontextus

  • 2000: vékonykliensek
  • 2010-től vastagkliensek, SPA-k
  • A kommunikáció is ennek megfelelően alakul
    • HTTP alapú (kérés-válasz)
      • HTML
      • AJAX
      • SSE
    • Websockets (nem HTTP)
    • WebRTC (peer-to-peer)

AJAX

XMLHttpRequest, fetch, + egyéb függvénykönyvtárak (pl. axios)

AJAX formátumok

  • RPC (Remote Procedure Call)
  • REST API
  • GraphQL

REST API

Erőforrások elérése és kezelése HTTP protokoll fölött

GraphQL

  • Speciális lekérdező nyelv API-knak
  • Strukturált üzenetek fogadására

Server-Sent Events

  • Szerver push üzenetek
  • Event Stream formátumban
const source = new EventSource('updates.php');
source.onmessage = function (event) {
  console.log(event.data);
};
<?php
header("Content-Type: text/event-stream\n\n");
echo 'data: ' . time() . "\n";
data: TROLOLOLOLOLOLOLOLOLOLO
data: Lorem ipsum dolor sit amet
data: consectetur adipiscing elit.
data: Vestibulum rutrum nisi in diam dapibus eget tempor mauris sollicitudin

Websockets

  • Kétirányú kapcsolat a kliens-szerver között
  • TCP feletti speciális protokoll
  • Hatékony

WebRTC

  • Közvetlen kapcsolat két böngésző között
  • Real-Time Communication, média

Serverless platforms

Backend-as-a-Service (BaaS), pl. Google Firebase

AJAX

  • RPC (Remote Procedure Call)
  • REST API
  • GraphQL

Aszinkron API

class StorageApi {
  async getItems() {
    const response = await fetch(url, {
      headers: {
        'Accept': 'application/json',
      },
    })
    const items = await response.json()
    return items
  }
  async addItem(item) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      body: JSON.stringify(item)
    })
    const newItem = await response.json()
    return newItem
  }
}

JSON-RPC

Dokumentáció

--> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
<-- {"jsonrpc": "2.0", "result": 19, "id": 1}

--> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}
<-- {"jsonrpc": "2.0", "result": -19, "id": 2}

JSON-RPC

declare(strict_types=1);
namespace App\Http\Procedures;
use Sajya\Server\Procedure;
class TennisProcedure extends Procedure {
  public static string $name = 'tennis';
  public function ping() {
    return 'pong';
  }
}
Route::rpc('/v1/endpoint', [TennisProcedure::class])->name('rpc.endpoint');
curl 'http://localhost:8000/api/v1/endpoint' \
  --data-binary '{"jsonrpc":"2.0","method":"tennis@ping","params":[],"id" : 1}'
[{"id":"1","result":"pong","jsonrpc":"2.0"}]

JSON-RPC

fetch('http://localhost:8000/api/v1/endpoint', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({jsonrpc:"2.0", method:"tennis@ping", params:[], id: 1})
})
  .then(response => response.json())
  .then(data => console.log(data));
[{"id":"1","result":"pong","jsonrpc":"2.0"}]

REST API

http://webprogramozas.inf.elte.hu:3030/tracks
fetch('http://webprogramozas.inf.elte.hu:3030/tracks', {
  headers: { 'Accept': 'application/json' },
})
  .then(response => response.json())
  .then(data => console.log(data));
{
  "total": 3,
  "limit": 10,
  "skip": 0,
  "data": [
    {
      "id": 1,
      "artist": "Bon Jovi",
      "title": "It's my life",
      "length": "3:44",
      "thumbnailURL": "",
      "spotifyURL": "https://open.spotify.com/track/0v1XpBHnsbkCn7iJ9Ucr1l",
      "chordsURL": "https://tabs.ultimate-guitar.com/tab/bon-jovi/its-my-life-chords-951538",
      "lyricsURL": "https://www.azlyrics.com/lyrics/bonjovi/itsmylife.html",
      "createdAt": "2020-04-24 13:15:42.228 +00:00",
      "updatedAt": "2020-04-24 13:15:42.228 +00:00",
      "userId": null
    },
    {
      "id": 2,
      "artist": "Bon Jovi",
      "title": "Livin' on a prayer",
      "length": "4:11",
      "thumbnailURL": null,
      "spotifyURL": null,
      "chordsURL": null,
      "lyricsURL": null,
      "createdAt": "2020-04-24 13:15:52.272 +00:00",
      "updatedAt": "2020-04-24 13:15:52.272 +00:00",
      "userId": null
    },
    {
      "id": 3,
      "artist": "AC/DC",
      "title": "Thunderstruck",
      "length": "4:52",
      "thumbnailURL": null,
      "spotifyURL": null,
      "chordsURL": null,
      "lyricsURL": null,
      "createdAt": "2020-04-24 13:16:37.675 +00:00",
      "updatedAt": "2020-04-24 13:16:37.675 +00:00",
      "userId": null
    }
  ]
}

GraphQL

REST API

  • Tiszta megvalósítása nehéz → HTTP API
  • Kliensnek funkciónként más-más adatokra van szüksége
  • A REST API nem ilyen formában adja
  • Az adat összeállítása a kliens feladata
  • Sok HTTP kérés
  • Kliens oldali join
  • Összetett kliensoldali logika
[
  {
    "title": "más",
    "description": "desc",
    "user": {
      "username": "q"
    }
  },
  {
    "title": "issue2",
    "description": "desc2",
    "user": {
      "username": "q"
    }
  },
  {
    "title": "issue3",
    "description": "desc3",
    "user": {
      "username": "w"
    }
  },
  {
    "title": "issue4",
    "description": "desc",
    "user": null
  }
]

Overfetching

Mi van, ha csak a szerzőre és a címre vagyunk kíváncsiak?

{
  "id": 1,
  "artist": "Bon Jovi",
  "title": "It's my life",
  "length": "3:44",
  "thumbnailURL": "",
  "spotifyURL": "https://open.spotify.com/track/0v1XpBHnsbkCn7iJ9Ucr1l",
  "chordsURL": "https://tabs.ultimate-guitar.com/tab/bon-jovi/its-my-life-chords-951538",
  "lyricsURL": "https://www.azlyrics.com/lyrics/bonjovi/itsmylife.html",
  "createdAt": "2020-04-24 13:15:42.228 +00:00",
  "updatedAt": "2020-04-24 13:15:42.228 +00:00",
  "userId": null
}

Underfetching

  • Mi van, ha kapcsolt információkra is szükségünk van?
  • N+1 probléma
  • Kliensoldali join
  • Pl. playlists (1) + tracks (N)
[
  {
    "id": 2,
    "title": "playlist1",
    "createdAt": null,
    "updatedAt": null
  },
  {
    "id": 7,
    "title": "playlist2",
    "createdAt": "2020-04-25T08:22:50.183Z",
    "updatedAt": "2020-04-25T08:22:50.183Z"
  }
]

GraphQL

  • Netflix, 2012, Backend for Frontend (BFF)
  • Facebook, 2015 (2012)
    • túl sok HTTP kérés a kliensektől
    • overfetching
  • → GraphQL
    • API leíró séma, típusrendszerrel, futtatókörnyezettel
    • Lekérdező és módosító nyelv API-khoz
type Query {
  me: User
}

type User {
  id: ID
  name: String
}
{
  me {
    name
  }
}
{
  "me": {
    "name": "Luke Skywalker"
  }
}

GraphQL

Adatformátum

A lekérdezés struktúrája megegyezik a kívánt adat struktúrájával

query {
  issues {
    title
    description
    user {
      username
    }
  }
}
{
  "data": {
    "issues": [
      {
        "title": "más",
        "description": "desc",
        "user": {
          "username": "q"
        }
      },
      {
        "title": "issue2",
        "description": "desc2",
        "user": {
          "username": "q"
        }
      },
      {
        "title": "issue3",
        "description": "desc3",
        "user": {
          "username": "w"
        }
      },
      {
        "title": "issue4",
        "description": "desc",
        "user": null
      }
    ]
  }
}

Séma

  • GraphQL Schema Definition Language (SDL)
  • Adatszerkezet leíró nyelv
  • Típusrendszerrel
  • Típusok
    • Skalár: Int, String, Boolean, Float, ID, enum
    • Listák: pl. [Int]
    • Objektum, mezőkkel, argumentumokkal
    • Query, Mutation
    • Interface
    • Union
    • Input

Példa

type Query {
  issues: [Issue]
  issueById(id: Int): Issue
}
type Issue {
  id: Int
  title: String
  description: String
  place: String
  status: String
  created_at: String
  user: User
}
type User {
  id: Int
  username: String
  role: String
  issues: [Issue]
}

type Mutation {
  createIssue(issue: IssueInput): Issue
  updateIssue(id: Int, issue: IssueInput): Issue
}
input IssueInput {
  title: String
  description: String
  place: String
  status: String
}

Lekérdezés

Mező

query HeroName {
  hero {
    name
  }
}
{
  "hero": {
    "name": "R2-D2"
  }
}

Argumentumok

query HumanWithHeight {
  human(id: "1000") {
    name
    height
  }
}
{
  "human": {
    "name": "Luke Skywalker",
    "height": 5.6430448
  }
}

Lekérdezés

Változók

query HeroNameAndFriends($episode: Episode = JEDI) {
  hero(episode: $episode) {
    name
    friends {
      name
    }
  }
}
{
  "episode": "JEDI"
}
{
  "hero": {
    "name": "R2-D2",
    "friends": [
      {
        "name": "Luke Skywalker"
      },
      {
        "name": "Han Solo"
      },
      {
        "name": "Leia Organa"
      }
    ]
  }
}

Lekérdezés

Direktívák

query Hero($episode: Episode, $withFriends: Boolean!) {
  hero(episode: $episode) {
    name
    friends @include(if: $withFriends) {
      name
    }
  }
}
{
  "episode": "JEDI",
  "withFriends": false
}
{
  "hero": {
    "name": "R2-D2"
  }
}

Módosítások

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
  createReview(episode: $ep, review: $review) {
    stars
    commentary
  }
}
type ReviewInput {
  stars: Int!
  commentary: String!
}
{
  "ep": "JEDI",
  "review": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}
{
  "createReview": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}

Implementációk

  • Szerveroldalon sok nyelvhez
  • Kipróbálás
    • GraphiQL
  • Kliens
    • natív HTTP, fetch, XMLHttpRequest
    • Relay (React)
    • apollo-client (React, Angular, iOS, Android, stb)

Példa

type Query {
  issues: [Issue]
  issueById(id: Int): Issue
}
type Issue {
  id: Int
  title: String
  description: String
  place: String
  status: String
  created_at: String
  user: User
}
type User {
  id: Int
  username: String
  role: String
  issues: [Issue]
}

type Mutation {
  createIssue(issue: IssueInput): Issue
  updateIssue(id: Int, issue: IssueInput): Issue
}
input IssueInput {
  title: String
  description: String
  place: String
  status: String
}

HTTP kliens

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"query": "{ hello }"}' \
  http://localhost:4000/graphql
{"data":{"hello":"Hello world!"}}

HTTP kliens

fetch('/graphql', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({query: "{ hello }"})
})
  .then(response => response.json())
  .then(data => console.log('data returned:', data));
data returned: Object { hello: "Hello world!" }

HTTP kliens

type Query {
  rollDice(numDice: Int!, numSides: Int): [Int]
}
const dice = 3;
const sides = 6;
const query = `query RollDice($dice: Int!, $sides: Int) {
  rollDice(numDice: $dice, numSides: $sides)
}`;

fetch('/graphql', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
  body: JSON.stringify({
    query,
    variables: { dice, sides },
  })
})
  .then(response => response.json())
  .then(data => console.log('data returned:', data));

Ne szövegösszefűzéssel adjuk meg a paramétereket!

GraphQL kliensek

  • Alacsony szintű megoldások kezdetben elegendőek
  • Alkalmazás méretének növekedtével érdemes lehet GraphQL kliens keretrendszert használni
  • Szolgáltatások
    • Cache
    • Refetch
    • Batch
    • Lokális állapot
    • Kliensoldali resolver

Apollo-client

npm install --save apollo-boost @apollo/react-hooks graphql
import ApolloClient, { gql } from 'apollo-boost';
import { ApolloProvider, useQuery, useMutation } from '@apollo/react-hooks';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
);

Lekérdezések

const GET_ISSUES = gql`
  query IssuesAndIssueById($id: Int) {
    issues { id title description }
    issueById(id: $id) { title description }
  }
`;
function App() {
  const [value, setValue] = useState('1')
  const { loading, error, data, refetch } = useQuery(GET_ISSUES, {
    variables: {
      id: parseInt(value)
    }
  });

  if (error) return <p>Error :(</p>;

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px', border: `${loading ? 5 : 0}px solid red`}}>
      <form>
        <input type="number" value={value} onChange={e => setValue(e.target.value)} />
        <button>Get issue by ID</button>
      </form>
      {data && data.issueById
        ? <p>{data.issueById.title} ({data.issueById.description})</p>
        : <p>No data</p>
      } 
      <ul>
        {data && data.issues && data.issues.map(({id, title, description}) => 
          <li key={id}>{title} ({description})</li>
        )}
      </ul>
      <button onClick={() => refetch()}>Refresh</button><br></br>
    </div>
  )
}

Módosítások

const CHANGE_ISSUE = gql`
  mutation UpdateIssue($id: Int, $issue: IssueInput) {
    updateIssue(id: $id, issue: $issue) { id title }
  }
`
function App() {
  const [value, setValue] = useState('1')
  const [title, setTitle] = useState('körte')
  const { loading, error, data, refetch } = useQuery(GET_ISSUES, {
    variables: {
      id: parseInt(value)
    }
  });
  ✒>const [changeIssue, {data: mutationData}] = useMutation(CHANGE_ISSUE)<✒

  ✒>const handleClick = () => changeIssue({
    variables: {
      id: parseInt(value),
      issue: {
        title
      }
    }
  })<✒

  if (error) return <p>Error :(</p>;

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px', border: `${loading ? 5 : 0}px solid red`}}>
      <form>
        <input type="number" value={value} onChange={e => setValue(e.target.value)} />
        <button>Get issue by ID</button>
      </form>
      {data && data.issueById
        ? <p>{data.issueById.title} ({data.issueById.description})</p>
        : <p>No data</p>
      } 
      <ul>
        {!loading && data.issues.map(({id, title, description}) => 
          <li key={id}>{title} ({description})</li>
        )}
      </ul>
      <button onClick={() => refetch()}>Refresh</button><br></br>
      <input type="text" value={title} onChange={e => setTitle(e.target.value)} />
      <button onClick={handleClick}>Change title</button>
    </div>
  )
}

Websocket

Használata

  • Kétirányú kommunikáció
  • Server-push üzenetek
  • Élő alkalmazások
  • Azonnali állapotszinkronizálás
  • Bináris adatok küldése

Implementációk

  • Szerveroldal
    • Sokféle nyelvi implementáció
  • Kliensoldal
    • Natív böngészőtámogatás
    • socket.io-client

Natív kliens

Dokumentáció

// Client
var socket = new WebSocket("ws://localhost:8080");

socket.onopen = function (event) {
  socket.send("Here's some text that the server is urgently awaiting!"); 
  socket.onmessage = function (event) {
    console.log(event.data);
  }
};

socket.close();
// Server
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: ', message);
    ws.send(message);
  });
  ws.send('something');
});

Socket.io

  • Népszerű könyvtár
    • kétirányú
    • valós idejű
    • esemény-vezérelt
  • Nem csak Websocket
    • AJAX
    • Long-polling HTTP
    • Websocket
  • De általában az
  • Nem natív implementáció

Socket.io

  • Megbízható kapcsolat
    • proxy, load balancer, stb
  • Újrakapcsolódás automatikusan
  • Szívverés mechanizmus
  • Bináris adatok továbbítása
  • Multiplexing
    • egy kapcsolaton többféle logikai csatorna
  • Szobák

Socket.io használata

npm install --save socket.io-client
import io from 'socket.io-client';

const socket = io('http://localhost:8080');

socket.on('news', (data) => {
  console.log(data);
  socket.emit('hello', { my: data });
});
const app = require('http').createServer()
const io = require('socket.io')(app);

app.listen(8080);

io.on('connection', (socket) => {
  socket.emit('news', { hello: 'world' });
  socket.on('hello', (data) => {
    console.log(data);
  });
});

Chat – szerver

const app = require('http').createServer()
const io = require('socket.io')(app);

app.listen(8080);

io.on('connection', function(socket){
  socket.on('chat message', function(msg){
    io.emit('chat message', msg);
  });
});

Chat – kliens

const socket = io('http://localhost:8080');

function App() {
  const [value, setValue] = useState('')
  const [messages, setMessages] = useState([])
  
  useEffect(() => {
    socket.on('chat message', msg => setMessages(messages => messages.concat(msg)))
    return () => {
      socket.off('chat message')
    }
  }, [])

  const handleSubmit = e => {
    e.preventDefault()
    socket.emit('chat message', value);
  }

  return (
    <div style={{backgroundColor: randomColor(), padding: '10px'}}>
      <form onSubmit={handleSubmit}>
        <input type="text" value={value} onChange={e => setValue(e.target.value)} />
        <button>Send message</button>
      </form>
      <ul>
        {messages.map((msg, i) => 
          <li key={i}>{msg}</li>
        )}
      </ul>
    </div>
  )
}

Websocket + Redux

Kérdések

  • Hogyan küldjünk?
  • Hogyan kapjunk?
  • Hol küldjünk?
  • Hol kapjunk?

Komponens szinten

import { socket } from 'socketUtil.js';
import { useDispatch } from 'react-redux';

function ChatRoomComponent(){
    const dispatch = useDispatch();

    useEffect(() => {
        socket.on('get-message', payload => {
            // update messages
            dispatch({ type: UPDATE_CHAT_LOG }, payload)
        });
    }, [dispatch]);
    
    const handleClick = () => {
      socket.emit('send-message', 'message')
    }
}
// socketUtil.js
export const socket = io.connect(WS_BASE)

React Context szint

import React, { createContext } from 'react'
import io from 'socket.io-client';
import { WS_BASE } from './config';
import { useDispatch } from 'react-redux';
import { updateChatLog } from './actions';

const WebSocketContext = createContext(null)

export { WebSocketContext }

export default ({ children }) => {
  let socket;
  let ws;

  const dispatch = useDispatch();

  const sendMessage = (roomId, message) => {
    const payload = {
      roomId: roomId,
      data: message
    }
    socket.emit("send-message", JSON.stringify(payload));
    dispatch(updateChatLog(payload));
  }

  if (!socket) {
    socket = io.connect(WS_BASE)

    socket.on("get-message", (msg) => {
      const payload = JSON.parse(msg);
      dispatch(updateChatLog(payload));
    })

    ws = {
      socket: socket,
      sendMessage
    }
  }

  return (
    <WebSocketContext.Provider value={ws}>
      {children}
    </WebSocketContext.Provider>
  )
}

Redux middleware szint

export const wsConnect = host => ({ type: 'WS_CONNECT', host });
export const wsConnecting = host => ({ type: 'WS_CONNECTING', host });
export const wsConnected = host => ({ type: 'WS_CONNECTED', host });
export const wsDisconnect = host => ({ type: 'WS_DISCONNECT', host });
export const wsDisconnected = host => ({ type: 'WS_DISCONNECTED', host });
function App() {
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(wsConnect(host));
    }, [dispatch]);
}

Redux middleware szint

import * as actions from '../modules/websocket';
import { updateGame, } from '../modules/game';

let socket = null;

const onOpen = store => (event) => {
  console.log('websocket open', event.target.url);
  store.dispatch(actions.wsConnected(event.target.url));
};

const onClose = store => () => {
  store.dispatch(actions.wsDisconnected());
};

const onMessage = store => (event) => {
  const payload = JSON.parse(event.data);
  console.log('receiving server message');

  switch (payload.type) {
    case 'update_game_players':
      store.dispatch(updateGame(payload.game, payload.current_player));
      break;
    default:
      break;
  }
};

// the middleware part of this function
export const socketMiddleware = store => next => action => {
  switch (action.type) {
    case 'WS_CONNECT':
      if (socket !== null) {
        socket.close();
      }

      // connect to the remote host
      socket = new WebSocket(action.host);

      // websocket handlers
      socket.onmessage = onMessage(store);
      socket.onclose = onClose(store);
      socket.onopen = onOpen(store);

      break;
    case 'WS_DISCONNECT':
      if (socket !== null) {
        socket.close();
      }
      socket = null;
      console.log('websocket closed');
      break;
    case 'NEW_MESSAGE':
      console.log('sending a message', action.msg);
      socket.send(JSON.stringify({ command: 'NEW_MESSAGE', message: action.msg }));
      break;
    default:
      console.log('the next action:', action);
      return next(action);
  }
};

Végszó

  • Kommunikációs formák
    • HTTP alapú (kérés-válasz)
      • AJAX
        • RPC
        • REST
        • GraphQL
      • SSE
    • Websockets (nem HTTP)
    • WebRTC (peer-to-peer)