Managing microservices manually can get very complicated and takes the focus away from business logic. API gateways help take care of the various collective management and housekeeping tasks necessary for running microservices.
One of the most popular API gateways is Kong. As the illustration below shows, Kong takes care of the tedious tasks involved in managing and running microservices while leaving you to focus on building exciting features by leveraging on products like Stream Chat for live chats or Stream Activity Feeds for creating engaging activity feeds.
In this tutorial, we will be creating a live chat app using Stream Chat and a backend microservice exposed through Kong's API gateway.
Prerequisites
Before you proceed with this tutorial, make sure you have Docker, Node.js, and npm or yarn installed on your machine. We'll be building the frontend with Svelte and the backend with Express. Both are chosen because of their simplicity and so that you can understand and follow along even if this is your first encounter with them. Being minimalist tools, they don't distract from what's going on.
Sign up for Stream Chat
Visit this link to create a free Stream account or login to your existing account.
Once you’re logged in, you can use the app that's automatically created for you. Or, if you prefer, go to the dashboard, hit the blue “Create App” button at the top right of the screen and give your app a name as shown below:
In the App Access Keys section at the bottom, copy your Key and Secret for use later.
Kong Setup
We'll be setting up Kong using Docker to keep our local machine clean and free of dependencies, and using Docker ensures that the steps are the same for all platforms.
We need to create a Docker network for Kong and our microservices, then setup Kong's database where its configurations will be stored, and start-up Kong after that.
$ docker network create kong-net $ docker run -d --name kong-database --network=kong-net \ -p 5432:5432 -e "POSTGRES_USER=kong" \ -e "POSTGRES_DB=kong" postgres:9.6 $ docker run --rm --network=kong-net -e "KONG_DATABASE=postgres" \ -e "KONG_PG_HOST=kong-database" -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \ kong:latest kong migrations bootstrap $ docker run -d --name kong --network=kong-net -e "KONG_DATABASE=postgres" \ -e "KONG_PG_HOST=kong-database" -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \ -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" \ -e "KONG_PROXY_ERROR_LOG=/dev/stderr" -e "KONG_ADMIN_ERROR_LOG=/dev/stderr" \ -e "KONG_ADMIN_LISTEN=0.0.0.0:8001, 0.0.0.0:8444 ssl" -p 8000:8000 -p 8443:8443 \ -p 8001:8001 -p 8444:8444 kong:latest
You can verify that Kong is running with:
1$ curl -i http://localhost:8001/
For Kong to know about our microservice, we need to configure the microservice and tell Kong how to route requests to our app. But, we first have to create the microservice.
Setting up the Chat Microservice
Let's create our folder structure, initialize the project, and install the needed dependencies:
$ mkdir -p kongchat/{frontend,backend} $ cd kongchat/backend $ touch index.js $ npm init -y $ npm i express body-parser dotenv stream-chat ip
After that, create a file called index.js
in the backend directory and paste in the code below:
require('dotenv').config(); const express = require('express'); const bodyParser = require('body-parser'); const ip = require('ip'); const { StreamChat } = require('stream-chat'); const app = express(); // Add the middlewares app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); // Initialize our Stream Chat client const streamClient = new StreamChat(process.env.KC_STREAM_CHAT_KEY, process.env.KC_STREAM_CHAT_SECRET); app.get('/api/kongchat/ping', (req, res) => { return res.json({ message: 'pong' }); }); app.post('/api/kongchat/token', (req, res) => { const { body: { username } } = req; // generate a Stream Chat token for use by the user making the request return res.json({ user: { username }, token: streamClient.createToken(username) }); }); app.listen(10000, () => { console.log(`API running on http://${ip.address()}:10000`); });
We have two routes. The /token
endpoint accepts a username and generates a new token using the Stream Chat Node.js SDK, as we need to authenticate any new users before they can gain access. The /ping
endpoint is to check if the server is running.
Create a .env
file in the current directory (backend) and add your key and secret you copied from your Stream dashboard:
KC_STREAM_CHAT_KEY=xxxx KC_STREAM_CHAT_SECRET=xxxx
In the scripts section of package.json
in the backend directory, add a start command to run the server as shown below:
{ "scripts": { "start": "node index.js" }, }
Start the server with:
1$ npm start
Now that we've successfully written our backend microservice let's Dockerize it so Kong can manage it.
Add a Dockerfile in the backend directory and paste in:
FROM node:carbon-alpine WORKDIR /app COPY package.json ./ RUN npm install COPY . . EXPOSE 10000 CMD ["npm", "start"]
Then, in the root directory (kongchat) add a docker-compose.yml
file and paste in:
version: '3' services: kc_backend: build: ./backend image: kc_backend container_name: kc_backend network_mode: kong-net
With this, we can run our backend service using:
1$ docker-compose up -d
Or, if you prefer it to run in the foreground:
1$ docker-compose up
Registering Our Microservice With Kong
Now our back-end microservice is up and running; we need to tell Kong about it. To do so, we need to know its IP address on the kong-net
network. We can find that out by running:
$ docker network inspect kong-net # Sample output [ { "Name": "kong-net", "Id": "1176776a97a0d22a44789cdd8fb4408cb80e4af086ac6474ab503704fda06c40", # ... "Containers": { # ... "3a21018c6c9a6c0877ac182e7cd0e3e3da0af87b36d1f2a4882f76fe43a03a27": { "Name": "kc_backend", "EndpointID": "8e7aec61796e745cff0e0e79d3e723b76dba52305c91d19aade6825a3e43d6e9", "MacAddress": "02:42:ac:13:00:04", "IPv4Address": "172.19.0.4/16", "IPv6Address": "" }, # ... } } ]
My microservice IP address, for example, is 172.19.0.4
, as seen from the IPv4Address
key of the Containers
field. I omitted the irrelevant parts of the output.
Now we have our IP address in hand; we register it with Kong by making a POST request to Kong's services endpoint:
$ curl -i -X POST --url http://localhost:8001/services/ --data 'name=kc-chat-backend' \ --data 'url=http://172.19.0.4' --data 'port=10000' # Sample output HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Connection: keep-alive Access-Control-Allow-Origin: * Server: kong/1.4.3 Content-Length: 295 X-Kong-Admin-Latency: 48 { "host": "172.19.0.4", "created_at": 1579439597, "connect_timeout": 60000, "id": "91e5cec5-abee-4b6c-b07c-11643a1678af", "protocol": "http", "name": "kc-chat-backend", "read_timeout": 60000, "port": 10000, "path": null, "updated_at": 1579439597, "retries": 5, "write_timeout": 60000, "tags": null, "client_certificate": null }
Now Kong knows about our microservice. We then need to tell Kong what requests to route to our microservice:
$ curl -i -X POST --url http://localhost:8001/services/kc-chat-backend/routes \ --data 'paths[]=/api/kongchat' --data 'strip_path=false' --data 'methods[]=GET' \ --data 'methods[]=POST' # Sample output HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Connection: keep-alive Access-Control-Allow-Origin: * Server: kong/1.4.3 Content-Length: 407 X-Kong-Admin-Latency: 30 { "id": "4fd4c22f-2760-49e2-9dda-64435ef4553f", "tags": null, "destinations": null, "headers": null, "protocols": [ "http", "https" ], "snis": null, "service": { "id": "91e5cec5-abee-4b6c-b07c-11643a1678af" }, "name": null, "preserve_host": false, "regex_priority": 0, "strip_path": false, "sources": null, "paths": [ "/api/kongchat" ], "https_redirect_status_code": 426, "hosts": null, "methods": [ "GET", "POST" ] }
With that, we just told Kong to route GET
and POST
requests made to /api/kongchat
to our microservice.
We can check that Kong is correctly routing our API requests to our microservice by sending a request to /ping
:
$ curl -i -X GET --url http://localhost:8000/api/kongchat/ping # Sample output HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Content-Length: 18 Connection: keep-alive X-Powered-By: Express ETag: W/"12-6FyCUNJCdUkgXM8yXmM99u6fQw0" X-Kong-Upstream-Latency: 22 X-Kong-Proxy-Latency: 1 Via: kong/1.4.3 { "message":"pong" }
Since we'll be communicating with Kong from the browser, we need to turn on the CORS plugin for our service:
$ curl -X POST http://kong:8001/services/kc-chat-backend/plugins \ --data "name=cors" \ --data "config.origins=*" \ --data "config.methods=GET" \ --data "config.methods=POST" \ --data "config.methods=OPTIONS" \ --data "config.headers=Accept" \ --data "config.headers=Accept-Version" \ --data "config.headers=Content-Length" \ --data "config.headers=Content-MD5" \ --data "config.headers=Content-Type" \ --data "config.headers=Host" \ --data "config.headers=Date" \ --data "config.headers=X-Auth-Token" \ --data "config.exposed_headers=X-Auth-Token" \ --data "config.credentials=true" \ --data "config.max_age=3600"
Creating Our Frontend
In the frontend directory, we created earlier, run:
npx degit sveltejs/template . npm i # install npm i stream-chat dotenv npm i -D postcss postcss-load-config svelte-preprocess tailwindcss @fullhuman/postcss-purgecss @rollup/plugin-replace # development dependencies npx tailwind init touch postcss.config.js cp ../backend/.env . # Make a copy of backend's .env in frontend npm run dev # and visit http://localhost:5000 in the browser
We're using Tailwind CSS for our styling. So let's integrate it into our development pipeline.
In postcss.config.js
, paste in:
const purgecss = require('@fullhuman/postcss-purgecss')({ content: ['./src/**/*.svelte', './src/**/*.html'], whitelistPatterns: [/svelte-/], defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || [] }) module.exports = { plugins: [ require('tailwindcss'), ...(!process.env.ROLLUP_WATCH ? [purgecss] : []) ] };
Replace the contents of rollup.config.js
with:
import svelte from 'rollup-plugin-svelte'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import livereload from 'rollup-plugin-livereload'; import { terser } from 'rollup-plugin-terser'; import sveltePreprocess from 'svelte-preprocess'; import replace from '@rollup/plugin-replace'; import { config } from 'dotenv'; config(); const production = !process.env.ROLLUP_WATCH; export default { input: 'src/main.js', output: { sourcemap: true, format: 'iife', name: 'app', file: 'public/build/bundle.js' }, plugins: [ svelte({ // enable run-time checks when not in production dev: !production, preprocess: sveltePreprocess({ postcss: true }), // we'll extract any component CSS out into // a separate file — better for performance css: css => { css.write('public/build/bundle.css'); } }), replace({ process: JSON.stringify({ env: { streamChatKey: process.env.KC_STREAM_CHAT_KEY, streamChatSecret: process.env.KC_STREAM_CHAT_SECRET, kongHost: process.env.KC_KONG_URL } }) }), // If you have external dependencies installed from // npm, you'll most likely need these plugins. In // some cases you'll need additional configuration — // consult the documentation for details: // https://github.com/rollup/plugins/tree/master/packages/commonjs resolve({ browser: true, dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/') }), commonjs(), // In dev mode, call `npm run start` once // the bundle has been generated !production && serve(), // Watch the `public` directory and refresh the // browser on changes when not in production !production && livereload('public'), // If we're building for production (npm run build // instead of npm run dev), minify production && terser() ], watch: { clearScreen: false } }; function serve() { let started = false; return { writeBundle() { if (!started) { started = true; require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { stdio: ['ignore', 'inherit', 'inherit'], shell: true }); } } }; }
Our changes complete the setup of our CSS pre-processing and inject our environment variables for use in the app.
Modify the style tag of App.svelte
as below:
<style global> @tailwind base; @tailwind components; @tailwind utilities; </style>
That's all for our CSS pre-processing. Let's dive into the mechanism of connecting to our proxied microservice.
When a user first loads the app, we want to show the login page below:
When the user types a username and clicks on Sign in, we want to show the chat window like the example below:
The contents of our App.svelte
is shown below:
<script> import { onMount } from 'svelte'; import { StreamChat } from 'stream-chat/dist/index.js'; export let appName; let loggedIn = false; let online = false; let token = ''; let username = ''; let message = ''; let messages = []; let channel = null; let user = null; let streamClient = null; let pingInterval = 30000; // 30 seconds const initializeStreamChat = async () => { if (!user) return; const { username } = user; streamClient = new StreamChat(process.env.streamChatKey); await streamClient.setUser({ id: username, name: username, role: 'admin' }, token); }; const initializeChannel = () => { channel = streamClient.channel('livestream', 'kongchat', { name: appName }); return channel.watch(); }; const joinChat = (_) => { if (!username) { alert('Username is empty'); return; } fetch(`${process.env.kongHost}/api/kongchat/token`, { method: 'POST', body: JSON.stringify({ username }), headers: { 'Content-Type': 'application/json' } }) .then(response => response.ok ? response.json() : ({})) .then((response) => { user = response.user; token = response.token; }) .then(initializeStreamChat) .then(initializeChannel) .then((room) => { loggedIn = true; messages = room.messages; channel.on('message.new', ({ message: { text, user } }) => { messages = [...messages, { text, user }]; }); }) .catch(console.error); }; const send = (_) => { channel.send({ text: message }); message = ''; }; const pingService = () => { fetch(`${process.env.kongHost}/api/kongchat/ping`, { method: 'GET' }) .then(response => response.ok ? response.json() : ({ message: 'not pong' })) .then(({ message }) => { online = message === 'pong'; }) .finally(() => { setTimeout(pingService, pingInterval); }); }; onMount(() => { pingService(); }); </script> <main> <h1 class="text-4xl text-center"> {appName} </h1> {#if online} <span class="bg-green-500 rounded-full h-5 w-5 flex mx-auto"></span> {:else} <span class="bg-red-500 rounded-full h-5 w-5 flex mx-auto"></span> {/if} {#if loggedIn} <div class="container mx-auto"> <h5>You are <strong>{user.username}</strong></h5> <br> {#each messages as message} <div class="w-full max-w my-1"> <div class="border border-gray-400 bg-white rounded p-4 flex flex-col justify-between leading-normal"> <div class="mb-8"> <p class="text-gray-700 text-base">{message.text}</p> </div> <div class="flex items-center"> <div class="text-sm"> <p class="text-gray-900 leading-none">By {message.user.id === user.username ? 'You' : message.user.name}</p> </div> </div> </div> </div> {/each} <form> <div class="flex items-center border-b border-b-2 border-teal-500 py-2 mb-10"> <input bind:value={message} class="appearance-none bg-transparent border-none w-full text-gray-700 mr-3 py-1 px-2 leading-tight focus:outline-none" type="text" placeholder="Message"> <button on:click|preventDefault={send} class="flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded" type="button"> Send </button> </div> </form> </div> {:else} <div class="w-full mx-auto max-w-xs"> <form on:submit|preventDefault={joinChat} class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"> <div class="mb-4"> <label class="block text-gray-700 text-sm font-bold mb-2" for="username"> Username </label> <input bind:value={username} class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username"> </div> <div class="flex items-center justify-between"> <button class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit"> Sign In </button> </div> </form> </div> {/if} <p class="text-center text-gray-500 text-xs inset-x-0 bottom-0 absolute"> ©{new Date().getFullYear()} {appName} </p> </main> <style global> @tailwind base; @tailwind components; @tailwind utilities; </style>
Let's go through the code.
At the beginning of our component's template, we have the app name and an indicator that shows if our microservice is up and running:
<h1 class="text-4xl text-center"> {appName} </h1> {#if online} <span class="bg-green-500 rounded-full h-5 w-5 flex mx-auto"></span> {:else} <span class="bg-red-500 rounded-full h-5 w-5 flex mx-auto"></span> {/if}
We call the /ping
endpoint we created on the microservice using pingService()
at intervals determined by pingInterval
.
When the user first opens the app (s)he is not logged in (loggedIn
is false) so we show the login form:
{#if loggedIn} <!-- --> {:else} <div class="w-full mx-auto max-w-xs"> <form on:submit|preventDefault={joinChat} class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"> <div class="mb-4"> <label class="block text-gray-700 text-sm font-bold mb-2" for="username"> Username </label> <input bind:value={username} class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username"> </div> <div class="flex items-center justify-between"> <button class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit"> Sign In </button> </div> </form> </div> {/if}
Submitting the login form will call joinChat()
which sends a request to the Kong gateway to get a token. When the token is returned, we initialize Stream Chat (initializeStreamChat()
) and our messaging channel (initializeChannel()
), then we set loggedIn
to true, retrieve previous messages (if any) and register a listener for new messages to the channel.
We then show the chat window for logged in users:
{#if loggedIn} <div class="container mx-auto"> <h5>You are <strong>{user.username}</strong></h5> <br> {#each messages as message} <div class="w-full max-w my-1"> <div class="border border-gray-400 bg-white rounded p-4 flex flex-col justify-between leading-normal"> <div class="mb-8"> <p class="text-gray-700 text-base">{message.text}</p> </div> <div class="flex items-center"> <div class="text-sm"> <p class="text-gray-900 leading-none">By {message.user.id === user.username ? 'You' : message.user.name}</p> </div> </div> </div> </div> {/each} <form> <div class="flex items-center border-b border-b-2 border-teal-500 py-2 mb-10"> <input bind:value={message} class="appearance-none bg-transparent border-none w-full text-gray-700 mr-3 py-1 px-2 leading-tight focus:outline-none" type="text" placeholder="Message"> <button on:click|preventDefault={send} class="flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded" type="button"> Send </button> </div> </form> </div> {:else} <!-- --> {/if}
When the Send
button is clicked, we publish the contents of message
to the channel and empty it after that, and Stream Chat ensures that all registered clients get the newly published message.
Demo
Open the app in different browser windows, log in with different usernames, and you can send messages between the logged-in users as shown in the images below:
Conclusion
You can find out more about Stream Chat and Kong by going through their documentation. The source code for this tutorial is hosted on GitHub.