เริ่มโค้ด Microservice part 2: to Microservice

Phai Panda
10 min readAug 10, 2020

--

สถาปัตยกรรม Monolith ถูกเปลี่ยนให้เป็น Microservice เพราะความคิดที่ต้องการการ scale สำหรับใน part นี้เราจะมาเตรียมโปรเจกต์ให้เป็น Microservice มาดูกันว่าเราจะโค้ดไหวไหม

ความเดิมจากตอนที่แล้ว

เราได้รู้จักสถาปัตยกรรมที่เรียกว่า Monolith กันไปแล้ว ล่าสุดเราได้สร้าง REST Controller ขึ้นมาจากความคิดของ RESTful Web Services ใช้ API หรือที่เรียกว่า REST API เป็น interface ระหว่าง client กับ server มี operation เป็น CRUD เมื่อว่ากันตาม Wiki พวกมันสัมพันธ์กับ HTTP Methods ดังตาราง

https://en.wikipedia.org/wiki/Create,_read,_update_and_delete

บางองค์กรเช่นที่แบงก์ทีม IT Security จะไม่อนุญาตให้ใช้ HTTP Methods อื่นยกเว้น GET กับ POST ส่งผลให้ @PutMapping และ @DeleteMapping ถูกแทนที่ด้วย @PostMapping

ในเมื่ออยากจะโค้ด Microservice ผมก็จะพยายามศึกษาและทำมันให้ได้ บทความนี้จึงเป็นการเติมเต็มความรู้ให้ตนเองในด้านของการอธิบายและหาองค์ประกอบร่วมของเรื่องราวที่เกี่ยวข้องทั้งยังช่วยให้ผู้ที่สนใจเห็นการ implement ไปพร้อมกัน

เมื่อเรามี REST API ก็ต้องสร้าง UI ใหม่ ซึ่งเราคุยกันว่าจะใช้ Vue

HCare Front end by Vue

hcare by vue

สำหรับ Vue แล้วผมเองไม่เคยจับเป็นชิ้นงานมาก่อน เคยฝึกมาบ้างแต่ไม่มากนัก มาลองดูว่าจะเข้าใจได้ไหมนะครับ

เครื่องมือที่ใช้

  • Node.js ติดตั้งเสร็จเราจะได้โปรแกรมจัดการจาวาสคริปต์ package ชื่อ npm
  • VS Code เป็น editor ที่เก่งมาก มี extension เยอะ ผมเลือกใช้ Vetur กับโปรเจกต์ front end นี้

ติดตั้ง Vue CLI

npm install -g @vue/cli

สร้างโปรเจกต์

vue create hcare
project structure

เบื้องต้นนี้เราต้องการ 3 หน้า ได้แก่

  • home
  • create employee
  • create man date

ความคิดในการจัดการ view ของ Vue คือแต่ละชิ้นบนหน้าจอเป็น component

ใน 1 หน้าจอให้เป็น 1 component แล้วในหน้านั้นสามารถแบ่งย่อยได้อีก ได้มากเท่าที่ต้องการ ลองคิดดูเพียงเท่านี้เราก็สามารถจัดการหน้าจอที่มีความซับซ้อนมากๆได้แล้วดังภาพ

view & components

โปรเจกต์ที่เพิ่งสร้างด้วย @vue-cli จะเป็น component base หมายความว่ามอง 1 ไฟล์ .vue เป็น 1 component ผมสร้างมา 3 ไฟล์ ได้แก่

  • Home.vue
  • CreateEmployee.vue
  • CreateManDate.vue

Vue component ใน 1 ไฟล์ประกอบด้วย 3 ส่วน คือ

  • template ไว้ render HTML
  • script ไว้เขียนจาวาสคริปต์
  • style ไว้เขียน CSS

และเมื่อผมได้เลือก webpack มาจัดการจาวาสคริปต์ module แล้วคำสั่ง
export default {}
จะมีผลให้จาวาสคริปต์ออบเจ็กต์ {} ถูกเรียกใช้งานได้ก็ต่อเมื่อ import

ภายในจาวาสคริปต์ออบเจ็กต์นั้นที่ใช้บ่อยมี 4 ส่วน ได้แก่

  • name คือชื่อของ component นี้ ตั้งได้ 2 แบบ เช่น create-employee หรือ CreateEmployee แนะนำให้ใช้อย่างหลัง เหตุผล
  • data คือตัวแปรที่จะ bind ระหว่างส่วนของ template กับ script ให้เขียนในรูปแบบฟังก์ชัน เพื่อจำกัด scope ให้ใช้ได้ภายใน component นี้เท่านั้น
  • methods คือฟังก์ชันที่ต้องการให้เรียกใช้ระหว่างส่วนของ template กับ script หรือภายใน script อย่างเดียวก็ได้
  • mounted คือหนึ่งใน Vue lifecycle ทำงานกับ Virtual DOM เป็นส่วนของฟังก์ชันที่เขียนติดต่อกับ REST API ผ่านไลบรารีชื่อ vue-axios

CSS ไลบรารีที่เลือกใช้คือ bootstrap-vue

Home.vue: Template part

<template>
<div class="container">
<div class="row">
<div class="col">
<b-table striped hover :items="items" :fields="fields">
<template v-slot:cell(no)="item">{{ item.index + 1 }}</template>
<template v-slot:cell(createManDate)="{item}">
<b-button variant="primary" :to="'/mandates/employees/' + item.id">เพิ่มชั่วโมง</b-button>
</template>
</b-table>
</div>
</div>
</div>
</template>

Home.vue: Script part

<script>
import Vue from "vue";
export default {
name: "Home",
data() {
return {
fields: [
{ key: "no", label: "ลำดับ" },
{ key: "firstName", label: "ชื่อ" },
{ key: "lastName", label: "นามสกุล" },
{ key: "createManDate", label: "" },
],
items: [],
};
},
methods: {},
mounted() {
Vue.axios.get("http://localhost:8080/api/employees").then((res) => {
this.items = res.data;
});
},
};
</script>

Home.vue: Style part

<style scoped>
</style>

scoped ที่เขียนใน style บอกได้ว่า CSS นี้จะใช้ภายใน component นี้เท่านั้น

หมายเหตุ ส่วนของ style part ที่ไม่ได้เขียน CSS เพิ่มเติมใดๆผมจะขอละไปนะครับ

CreateEmployee.vue: Template part

<template>
<div class="container">
<div class="row">
<div class="col col-6">
<b-form @submit.prevent="onSubmit" @reset.prevent="onReset">
<b-form-group label="ชื่อ" label-for="firstName">
<b-form-input id="firstName" v-model="form.firstName" type="text" required></b-form-input>
</b-form-group>
<b-form-group label="นามสกุล" label-for="lastName">
<b-form-input id="lastName" v-model="form.lastName" type="text" required></b-form-input>
</b-form-group>
<div>
<b-button type="submit" variant="primary" class="mr-3">เพิ่ม</b-button>
<b-button type="reset">ล้างค่า</b-button>
</div>
</b-form>
</div>
</div>
</div>
</template>

CreateEmployee.vue: Script part

<script>
import Vue from "vue";
export default {
name: "CreateEmployee",
data() {
return {
form: {
firstName: "",
lastName: "",
},
};
},
methods: {
onSubmit() {
const payload = {
firstName: this.form.firstName,
lastName: this.form.lastName,
};
const options = {
headers: { "Content-Type": "application/json" },
};
Vue.axios
.post("http://localhost:8080/api/employees", payload, options)
.then(() => {
this.$router.push("/");
});
},
onReset() {
this.form.start = "";
this.form.end = "";
},
},
};
</script>

CreateManDate.vue: Template part

<template>
<div class="container">
<div class="row">
<div class="col col-6">
<b-form @submit.prevent="onSubmit" @reset.prevent="onReset">
<div class="d-flex justify-content-between">
<b-form-group label="เริ่ม" label-for="start">
<b-form-input id="start" v-model="form.start" type="datetime-local" required></b-form-input>
</b-form-group>
<b-form-group label="สิ้นสุด" label-for="end">
<b-form-input id="end" v-model="form.end" type="datetime-local" required></b-form-input>
</b-form-group>
</div>
<div>
<b-button type="submit" variant="primary" class="mr-3">เพิ่ม</b-button>
<b-button type="reset">ล้างค่า</b-button>
</div>
</b-form>
</div>
</div>
<div class="row">
<div class="col">
<div class="mt-3">
<b-table striped hover :items="items" :fields="fields">
<template v-slot:cell(no)="item">{{ item.index + 1 }}</template>
<template v-slot:cell(start)="{value}">{{value | moment("DD-MM-YYYY HH:mm")}}</template>
<template v-slot:cell(end)="{value}">{{value | moment("DD-MM-YYYY HH:mm")}}</template>
<template v-slot:cell(deleteManDate)="{item}">
<b-button variant="danger" @click.prevent="onDeleteManDate(item.id)">ลบ</b-button>
</template>
</b-table>
</div>
</div>
</div>
</div>
</template>

CreateManDate.vue: Script part

<script>
import Vue from "vue";
export default {
name: "CreateManDate",
data() {
return {
form: {
start: "",
end: "",
},
fields: [
{ key: "no", label: "ลำดับ" },
{ key: "start", label: "เริ่ม" },
{ key: "end", label: "สิ้นสุด" },
{ key: "deleteManDate", label: "" },
],
items: [],
};
},
methods: {
onSubmit() {
const employeeId = this.$route.params.id;
const payload = { start: this.form.start, end: this.form.end };
this.createManDateByEmployeeId(employeeId, payload);
},
onReset() {
this.form.start = "";
this.form.end = "";
},
findAllManDatesByEmployeeId(employeeId) {
Vue.axios
.get("http://localhost:8080/api/mandates/employees/" + employeeId)
.then((res) => {
this.items = res.data
.slice()
.sort((a, b) => new Date(b.start) - new Date(a.start));
});
},
createManDateByEmployeeId(employeeId, payload) {
const options = {
headers: { "Content-Type": "application/json" },
};
Vue.axios
.post(
"http://localhost:8080/api/mandates/employees/" + employeeId,
payload,
options
)
.then(() => {
this.findAllManDatesByEmployeeId(employeeId);
});
},
onDeleteManDate(manDateId) {
const employeeId = this.$route.params.id;
this.deleteManDateByIdAndEmployeeId(manDateId, employeeId);
},
deleteManDateByIdAndEmployeeId(manDateId, employeeId) {
Vue.axios
.delete(
"http://localhost:8080/api/mandates/" +
manDateId +
"/employees/" +
employeeId
)
.then(() => {
this.items = this.items.filter((manDate) => manDate.id !== manDateId);
});
},
},
mounted() {
const employeeId = this.$route.params.id;
this.findAllManDatesByEmployeeId(employeeId);
},
};
</script>

ทั้ง 3 หน้านี้เชื่อมกันด้วย routes ให้ vue-router จัดการเรื่องนี้ครับ

router: index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../components/Home'
import CreateEmployee from '../components/CreateEmployee'
import CreateManDate from '../components/CreateManDate'
Vue.use(VueRouter)const routes = [
{
path: '/mandates/employees/:id',
name: 'CreateManDate',
component: CreateManDate
},
{
path: '/employees/create',
name: 'CreateEmployee',
component: CreateEmployee
},
{
path: '/',
name: 'Home',
component: Home
},
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
component: {
Home,
CreateEmployee,
}
})
export default router

property ชื่อ name ในออบเจ็กต์ route ข้างต้นจะไม่เขียนไว้ก็ได้เพราะในตัวอย่างผมไม่ได้เรียกใช้เลย ที่ติดมาเพราะทำไปตาม document เฉยๆ แต่หากอยากจะทราบว่ามีไว้ทำไม อ่าน

App.vue

<template>
<div id="app">
<Header />
<router-view />
</div>
</template>
<script>
import Header from "./components/Header";
export default {
name: "App",
components: {
Header,
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
</style>

และสุดท้ายคือที่ลงทะเบียนรวม

main.js

import Vue from 'vue'
import App from './App.vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import router from './router'
import VueAxios from "vue-axios";
import axios from "axios";
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
Vue.config.productionTip = false
Vue.use(BootstrapVue);
Vue.use(IconsPlugin);
Vue.use(VueAxios, axios);
Vue.use(require('vue-moment'));
new Vue({
router,
render: h => h(App),
}).$mount('#app')

CORS

ย่อมาจาก Cross-Origin Resource Sharing เป็นการกำหนดการอนุญาตหรือไม่อนุญาตให้เข้าถึง resources ของ server จาก origin ที่แตกต่างกัน ไม่ว่าจะเป็น domain, protocol หรือ port

ในกรณีของเราคือ localhost:8080 ซึ่งเป็น back end เป็นผู้ให้บริการ resources ผ่าน REST API แก่ localhost:5000 ซึ่งเป็น front end

back end จะต้องอนุญาตให้ front end สามารถเข้าถึง resources ได้ ดังนั้นคลาส main ชื่อ HcareApplication เพิ่มโค้ดต่อไปนี้

...import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@EnableAutoConfiguration
@SpringBootApplication
public class HcareApplication implements WebMvcConfigurer {
...@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**").allowedOrigins("http://localhost:5000").allowedMethods("GET", "POST", "DELETE");
}
} // end class

โปรเจกต์ hcare front end นี้สามารถเพิ่ม feature ได้อีก แต่เอาเท่านี้ก่อน เป็นอันว่าตอนนี้เราได้แยก UI ออกมาจากฝั่ง back end เป็นที่เรียบร้อยโดยเชื่อมต่อมันทั้งสองด้วย REST API

มือใหม่อย่าได้สับสนระหว่าง Vue component กับ software component นะครับ ตัว software component เป็น architecture design ใช้อธิบายความซับซ้อนของระบบโดยแยกออกเป็น component ต่างๆ จากภาพ
- REST API ก็คือ controller ให้เป็น component จัดการเรื่อง URL, URI
- Service เป็น component จัดการเรื่อง business logic
- Repository เป็น component จัดการเรื่อง data access objects ซึ่งเป็นเรื่องการใช้ข้อมูลจากฐานข้อมูล เป็นต้นว่าขอดู (query), เพิ่ม (insert), แก้ไข (update), ลบ (delete)

เริ่มคิด Microservice

ให้สังเกตว่า front end ใหม่นี้ผมไม่ได้เขียนส่วนคำนวณจำนวนชั่วโมงทั้งหมดออกมา ส่วนนี้หายไป

ความจริงผมจะเขียนส่วนนี้ให้เหมือนเดิมก็ได้แต่ผมตัดสินใจแยกมันออกเพื่อนำไปสร้างเป็นโปรเจกต์ใหม่ เหตุที่ทำก็เพื่อจะสาธิตจุดเริ่มต้นของ Microservice

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

จากตัวอย่างผมขอชี้ให้เห็นว่า

Separation of Concerns

กังวลส่วนไหนให้แยกส่วนนั้น ส่วนที่กังวลหมายความว่า

  • มันเป็นส่วนที่เติบโตตามการใช้งาน
  • มีเงื่อนไขซับซ้อนมากยากแก่การพัฒนาในระยะเวลาอันสั้น
  • ประกอบด้วยการทำงานจากหลายภาคส่วน อาจไม่สามารถใช้ programming language ภาษาเดียวพิชิตได้ ตลอดจนไม่ถูก deploy บนสภาพแวดล้อมเดียวกัน

ภายใต้เงื่อนไขกว้างๆข้างต้นเมื่อหันมามอง Monolith แบบเดิมจึงได้ว่าจำนวนชั่วโมงที่เกิดเกิดจากผลรวมจำนวนชั่วโมงในแต่ละช่วงวัน ตรงนี้สามารถเพิ่ม feature ได้อีก ไม่ว่าจะเป็น

  • จำนวนชั่วโมงทั้งหมดของทั้งเดือน
  • เลือกเดือนที่ต้องการได้ กำหนดให้เดือนปัจจุบันเป็น default
  • จัดอันดับจำนวนชั่วโมงสูงสุดของพนักงาน (ranking) ภายในเดือนนั้น
  • เรียงลำดับค่าจ้างมากที่สุดของพนักงานจากมากไปหาน้อย
  • พิมพ์รายงาน (report) ตามเดือนที่กำหนดในรูปแบบ PDF, Excel ได้

การแยกออกลักษณะนี้เรียกว่า loosely-coupled นำมาซึ่งแต่ละส่วนเป็นอิสระจากกัน สามารถพัฒนาแยกกันได้โดยส่งผลกระทบต่อกันน้อยมากหรือไม่เกิดผลกระทบเลย

Independent Changes

ในอนาคตอันใกล้นี้เราจะมีมากกว่าหนึ่งโปรเจกต์

  • hcare (back end) ทำแล้ว
  • hcare-in-hour (back end) ยังไม่เกิด
  • hcare (front end) ทำแล้ว

หากเรา deploy แต่ละโปรเจกต์แยกกัน คำว่า dependent คือเกี่ยวพันธ์กัน ยึดโยงกัน ขาดจากกันไม่ได้ก็จะไม่เกิดขึ้นเพราะ codebase ถูกแยกไปแล้ว คำว่า independent จะเกิดขึ้นแทน คือเป็นอิสระจากกัน ณ จุดนี้เราสามารถทดสอบแต่ละระบบแยกกันได้ (fake data test) ลดเวลาและงานที่ต้องดูแลเมื่อมีการเปลี่ยนแปลงใดๆเกิดขึ้นกับแต่ละโปรเจกต์

Scalability

ประโยชน์ของการ scale ต้องตอบเรื่อง responding on time หรือระยะเวลาที่ระบบตอบสนองเมื่อมีการร้องขอให้มีค่าน้อยที่สุด ระบบที่มีค่า responding on time สูงจะยากต่อการ scale

responding on time สูง รู้ได้อย่างไรว่าสูง? ประเมินง่ายที่สุดจาก user หรือผู้ใช้งานครับ ถ้าเขาบ่นว่ามันช้าไม่ทันใจนั่นแหละคือสูง (ซีเรียสมาก)

จินตนาการว่าเรามี Monolith ที่มีบางหน้าหรือตัวระบบมีค่า responding on time สูง เมื่อต้องการแก้ไขสถาปัตยกรรมแบบนี้ทางออกของความคิดเก่าคือให้ deploy เพิ่ม instance ไปเรื่อยๆ นั่นหมายความว่าเครื่อง server ที่รองรับก็ต้องประสิทธิภาพสูงตาม ค่าใช้จ่ายมากขึ้น แพงขึ้นทั้งหมดทั้งๆที่เราต้องการปรับปรุงแค่บางส่วนของระบบให้ค่า responding on time ต่ำลงเท่านั้น

เพราะผมบอกว่าจะสร้างโปรเจกต์ใหม่ ในที่นี้ตั้งชื่อว่า hcare-in-hour (back end) แต่ก่อนจะไปต่อ ผมมี 3 ข้อให้คิดไปพร้อมกัน ลองตัดสินใจดูครับว่าเลือกข้อไหนเมื่อจะทำเป็น Microservice

  1. ให้ hcare-in-hour (โปรเจกต์ใหม่) ใช้ฐานข้อมูลร่วมกับ hcare เกิดเป็น data center ระหว่างกัน
  2. ให้ hcare-in-hour ดึงข้อมูลจาก REST API ของ hcare เป็นระยะๆ (poll data periodically) แทนการใช้ฐานข้อมูลร่วมกัน เพราะข้อมูลที่ใช้ได้มาจาก hcare อยู่แล้ว
  3. เมื่อใดก็ตามที่เกิดการเปลี่ยนแปลงข้อมูลของ hcare ให้สร้างกลไกไปเปลี่ยนแปลงข้อมูลของ hcare-in-hour ด้วย (ไม่เอา poll data periodically) และแยกฐานข้อมูลเพื่อเก็บข้อมูลเฉพาะ เช่น อันดับ (ranking), รายงาน (report)

.

.

.

ผมเลือกข้อ 3. ด้วยเหตุผลของ Separation of Concerns, Independent Changes และ Scalability ดังที่ได้อธิบายไปครับ

คำถามคือจะใช้ความคิดใดและเทคโนโลยีอะไรมาจัดการช่องว่างระหว่าง 2 โปรเจกต์ให้มันทั้งคู่สามารถคุยกันได้?
คำตอบ ผมเลือก Event-driven architecture เป็นความคิดและเลือก RabbitMQ เป็นเทคโนโลยีมา implement ความคิดดังกล่าว ภายใน RabbitMQ มี AMQP Model จัดการเรื่อง messaging ให้เรา

เมื่อ Spring framework มี Spring AMQP จัดการเรื่อง messaging แน่นอนว่า Spring Boot ก็ต้องมีไม่ต่างกัน (อ่านเพิ่มเติม) มันชื่อว่า spring-boot-starter-amqp สิ่งที่ผมกำลังจะบอกคือ โปรเจกต์ hcare (back end) ผมจะเพิ่ม spring-boot-starter-amqp dependency ทำให้มันกลายเป็นผู้ผลิต เรียกว่า Publisher ส่ง message ที่อยู่ในรูปของ event ไปยังผู้ต้องการบริโภคที่เรียกว่า Subscriber ใดๆที่ได้ subscribe ไว้ผ่านคนกลางที่เรียกว่า broker

hcare & hcare-in-hour messaging

hcare with spring-boot-starter-amqp

โปรเจกต์เดิม hcare (back end) เพิ่ม dependency

  • Spring for RabbitMQ (spring-boot-starter-amqp)

ไฟล์ pom.xml

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

จากนั้นเพิ่ม 2 packages ชื่อ

  • configuration
  • event

configuration package สร้างคลาสชื่อ RabbitMQConfiguration ไว้ภายใน รับผิดชอบ serialize เปลี่ยนออบเจ็กต์เป็น json ด้วย Jackson2JsonMessageConverter

event package สร้างคลาสชื่อ EventDispatcher และ HCareCreateManDateEvent

EventDispatcher รับผิดชอบส่ง event ซึ่งคือคลาส HCareCreateManDateEvent ไปยัง broker (RabbitMQ broker)

รายละเอียดของทั้งสามคลาสตามลำดับ

คลาส RabbitMQConfiguration

package com.pros.exam.hcare.configuration;

import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfiguration {
@Bean
public TopicExchange topicExchange(@Value("${hcare.exchange}") String exchange) {
return new TopicExchange(exchange);
}

@Bean
public Jackson2JsonMessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}

@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate();
template.setConnectionFactory(connectionFactory);
template.setMessageConverter(jsonMessageConverter());

return template;
}
}

คลาส EventDispatcher

package com.pros.exam.hcare.event;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class EventDispatcher {
private RabbitTemplate rabbitTemplate;
private String hcareExchange;
private String hcareRoutingKey;

@Autowired
public EventDispatcher(
RabbitTemplate rabbitTemplate,
@Value("${hcare.exchange}") String hcareExchange,
@Value("${hcare.routing.key}") String hcareRoutingKey
) {
this.rabbitTemplate = rabbitTemplate;
this.hcareExchange = hcareExchange;
this.hcareRoutingKey = hcareRoutingKey;
}

public void send(HCareCreateManDateEvent event) {
rabbitTemplate.convertAndSend(hcareExchange, hcareRoutingKey, event);
}
}

คลาส HCareCreateManDateEvent

ลักษณะเป็น model ธรรมดาที่ implment Serializable

package com.pros.exam.hcare.event;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
@EqualsAndHashCode
@AllArgsConstructor
@Data
public class HCareCreateManDateEvent implements Serializable {
private Long employeeId;
private Long manDateId;
private Date manDateStart;
private Date manDateEnd;
}

เราจะส่ง event ก็ต่อเมื่อมีการบันทึก man date ใหม่ลงไปในฐานข้อมูลแล้ว เรามีคลาส ManDateServiceImpl รับผิดชอบเรื่องนี้ ดังนั้นเพิ่มโค้ดการส่ง event ลงไป

คลาส ManDateServiceImpl

@Override
public ManDate create(Long employeeId, ManDate manDate) {
manDate.setEmployeeId(employeeId);
ManDate newManDate = repository.save(manDate);

eventDispatcher.send(new HCareCreateManDateEvent(employeeId, manDate.getId(), manDate.getStart(), manDate.getEnd()));

return newManDate;
}

อย่าลืม inject ออบเจ็กต์ EventDispatcher เข้ามาด้วย ผมเลือกผ่านทาง constructor เหมือนเคย

private EventDispatcher eventDispatcher;

@Autowired
public ManDateServiceImpl(
ManDateRepository repository,
EventDispatcher eventDispatcher
) {
this.repository = repository;
this.eventDispatcher = eventDispatcher;
}

ไฟล์ application.properties

สุดท้ายคือปรับปรุงไฟล์ application.properties เพิ่ม configuration ของ broker เข้าไปพร้อมกำหนด port number ของโปรเจกต์นี้

...
#port
server.port=8080
#RabbitMQ configuration
hcare.exchange=hcare_exchange
hcare.routing.key=hcare.mandate.create

hcare.exchange บอกว่า TopicExchange นี้ชื่ออะไร

hcare.routing.key คือ routingKey ค่านี้ระหว่าง publisher กับ subscriber ต้องสอดคล้องกัน

hcare-in-hour with spring-boot-starter-amqp

สร้างโปรเจตก์ใหม่ชื่อ hcare-in-hour ด้วย Spring Boot ใช้ Java 1.8 ใช้ Maven เป็น build tool

by https://start.spring.io/

Spring Boot Dependencies

  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Lombok
  • Spring for RabbitMQ

Project Structure

เพิ่ม 2 packages

  • configuration
  • event

configuration package สร้างคลาสชื่อ RabbitMQConfiguration ไว้ภายใน รับผิดชอบ deserialize เปลี่ยน json เป็นออบเจ็กต์เป้าหมาย สำคัญที่นี่ได้กำหนดช่องทาง binding ของ queue, topic exchange และ routing key ไว้ล่วงหน้า

event package สร้างคลาสชื่อ EventHandler และ HCareCreateManDateEvent

คลาส EventHandler รับผิดชอบกำหนด queue เข้ากับช่องทาง binding ที่ถูกเตรียมไว้แล้ว

รายละเอียดของทั้งสามคลาสเป็นตามนี้

คลาส RabbitMQConfiguration

package com.pros.exam.hcareinhour.configuration;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.RabbitListenerConfigurer;
import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistrar;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;

@Configuration
public class RabbitMQConfiguration implements RabbitListenerConfigurer {
@Bean
public TopicExchange topicExchange(@Value("${hcare.exchange}") String exchange) {
return new TopicExchange(exchange);
}

@Bean
public MappingJackson2MessageConverter jsonMessageConverter() {
return new MappingJackson2MessageConverter();
}

@Bean
public Queue queue(@Value("${hcare.queue}") String queue) {
return new Queue(queue, true);
}

@Bean
public Binding binding(Queue queue, TopicExchange exchange, @Value("${hcare.routing.key}") String routingKey) {
return BindingBuilder.bind(queue).to(exchange).with(routingKey);
}


@Bean
public DefaultMessageHandlerMethodFactory messageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory factory = new DefaultMessageHandlerMethodFactory();
factory.setMessageConverter(jsonMessageConverter());

return factory;
}

@Override
public void configureRabbitListeners(RabbitListenerEndpointRegistrar registrar) {
registrar.setMessageHandlerMethodFactory(messageHandlerMethodFactory());
}

}

คลาส EventHandler

package com.pros.exam.hcareinhour.event;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class EventHandler {
@RabbitListener(queues = "${hcare.queue}")
void handler(HCareCreateManDateEvent event) {
log.info("Event received: Employee Id {}, ManDate Id {}, Start Date {}, End Date {}", event.getEmployeeId(), event.getManDateId(), event.getManDateStart(), event.getManDateEnd());
}
}

คลาส HCareCreateManDateEvent

package com.pros.exam.hcareinhour.event;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.io.Serializable;
import java.util.Date;

@EqualsAndHashCode
@AllArgsConstructor
@Data
public class HCareCreateManDateEvent implements Serializable {
private Long employeeId;
private Long manDateId;
private Date manDateStart;
private Date manDateEnd;

public HCareCreateManDateEvent() { }
}

ไฟล์ application.properties

#port
server.port=8081
#RabbitMQ configuration
hcare.exchange=hcare_exchange
hcare.routing.key=hcare.mandate.create
hcare.queue=hcare_queue

สุดท้ายคือโปรแกรม RabbitMQ จะต้องถูกติดตั้งและรันอยู่ตลอดเวลา

RabbitMQ

ดาวน์โหลดและติดตั้งตามขั้นตอนได้ที่ rabbitmq.com/download แต่ผมใช้ MacOS สามารถติดตั้งผ่าน Homebrew ได้

brew install rabbitmq
install rabbitmq

รันแบบ background

brew services start rabbitmq

รันอยู่ที่ http://localhost:15672 ใช้ username: guest กับ password: guest

Running hcare & hcare-in-hour Projects

เริ่มต้นจาก rerun โปรเจกต์ hcare back end ก่อน จากนั้นสร้าง man date ใหม่ที่ front end

hcare back end ผล log บอกว่าเชื่อมต่อได้สำเร็จ

Attempting to connect to: [localhost:5672]Created new connection: rabbitConnectionFactory#3ce53f6a:0/SimpleConnection@77354a92 [delegate=amqp://guest@127.0.0.1:5672/, localPort= 54305]

hcare-in-hour ผล log บอกว่าได้รับ event ถูกต้อง

Attempting to connect to: [localhost:5672]Created new connection: rabbitConnectionFactory#6d2d99fc:0/SimpleConnection@2c306a57 [delegate=amqp://guest@127.0.0.1:5672/, localPort= 54291]Started HcareInHourApplication in 2.412 seconds (JVM running for 2.722)Event received: Employee Id 1, ManDate Id 4, Start Date Mon Aug 10 08:00:00 ICT 2020, End Date Mon Aug 10 16:30:00 ICT 2020

ดูที่ RabbitMQ console การเชื่อมต่อของทั้งสองโปรเจกต์ถูกต้อง (hcare & hcare-in-hour back end projects)

สรุป

สถาปัตยกรรม Monolith ถูกเปลี่ยนให้เป็น Microservice เพราะความคิดที่ต้องการการ scale เพื่อรองรับปริมาณการร้องขอข้อมูลจำนวนมากที่จะเกิดขึ้นกับบาง service อย่างรู้แน่ชัด การแยกกลุ่มของ service หรือแค่ service เดียวให้เป็นอิสระยังง่ายต่อการพัฒนาและกำหนดทิศทางการเติบโตในอนาคต

ด้วยความคิดดังกล่าวส่งผลให้ตัวอย่างของโปรเจกต์ back end ทั้งหมดนับจากนี้ไปถูกเรียกว่า Microservice ครับ

แล้วพบกันใหม่ใน part ถัดไป สวัสดีจ้า

อ้างอิง

https://spring.io/guides/gs/messaging-rabbitmq/

https://onlinehelp.coveo.com/en/ces/7.0/administrator/changing_the_rabbitmq_administrator_password.htm

--

--

No responses yet