webOS Article/3. Web Service 개발하기

heartbeat 사용하기

하이하정 2021. 9. 19. 21:55

이 포스팅은 webOS OSE 개발자 사이트를 참고하여 작성되었습니다.  

heartbeat 기능은 백그라운드에서 서비스를 구동하기를 위해 생성합니다.

 

[참고]

다른 어플 실행 중에 내가 만든 서비스로 모니터링하기

 

 

 webOS에서 다른 어플리케이션/서비스를 실행하는 도중에도 백그라운드에서 꺼지지 않고 서비스가 실행되어야 하는 경우가 있습니다. 유튜브 영상 시청하는 도중에도, 메세지 알림이 오는 경우가 그 예입니다. (서비스 : 메시지 알림) 

 

 이번 포스트에서는 heartbeat 서비스를 생성하여 백그라운드에서 원하는 기능을 동작시키는 방법을 다룹니다. webOS OSE에서는 서비스를 단독으로 만들어서 설치하는 것이 안되므로 web application과 함께 만들어 설치를 진행하였습니다. 

 

heartbeat가 필요한 이유 


webOS에서는 서비스가 실행된 후 약 5초 뒤에 자동적으로 서비스가 종료되었습니다. 
하지만 서비스를 효과적으로 활용하기 위해선 별도의 동작 없이 서비스가 종료되는 것은 바람직하지 않습니다.

따라서 heartbeat를 통해 서비스가 자동으로 죽지 않도록 합니다.


heartbeat는 주기적으로 구독자에게 beat를 몇 초 단위로 전송합니다. 
구독자가 일정 시간동안 beat를 수신하지 않으면, 즉 heartbeat에 대한 응답을 받지 못하면 서비스가 죽은 것으로 판단합니다. 따라서 beat를 계속 전송하여 서비스가 죽었는지 살았는지 확인하기 위해 heartbeat 서비스를 사용하며, 이를 통해 약 5초였던 기존 제한 없이 서비스가 계속 실행될 수 있습니다. 

 

heartbeat의 구성 및 흐름


  heartbeat 서비스 사용의 큰 흐름을 정리하면 다음과 같습니다. 

  • 백그라운드에서 동작시키려는 서비스 생성 (이하 '동작서비스', 본 포스트에서는 serviceOn에 해당)
  • 동작서비스가 실행
  • 동작서비스 내에서 heartbeat 서비스를 구독
  • heartbeat 서비스가 구독 대상에게 beat를 계속 전송하며, 구독 대상 서비스(=동작서비스)가 background에서 계속 동작하게 함
    • 본 포스트에서는 heartbeat 서비스에서 구독 대상에게 1초 간격으로 beat를 전송하도록 설정하였습니다.
    • 동작서비스에서 heartbeat 서비스가 보낸 beat를 잘 수신하고 있는지를 모니터링 하기 위해 3초마다 토스트알림 띄우도록 설정하였습니다. (5번 반복)
    • 120초 모니터링 후 heartbeat 구독 취소, 모니터링이 종료되도록 설정하였습니다.

 

코드 설명 : app


1. appinfo.json 

전체코드

{
  "id": "com.heartbeat.app",
  "version": "1.0.0",
  "vendor": "My Company",
  "type": "web",
  "main": "index.html",
  "title": "Heartbeat Demo",
  "icon": "icon.png",
  "requiredPermissions": [
    "time.query",
    "activity.operation",
    "notification.operation",
    "com.heartbeat.app.service.group"
  ]
}

 

service를 단독으로 설치할 수 없기 때문에 web app을 함께 만들어줍니다. 

requirePermissions를 추가해줍니다.

 

[주의사항]

호출하고싶은 외부 service 모듈의 Access Control Group을 requiredPermissions에 추가시켜주어야합니다.

  • "notification.operation" : toast를 띄우기 위해 추가해야하는 ACG입니다.
  • "com.heartbeat.app.service.group" : heartbeat 서비스를 이용하기 위해 자기자신의 서비스 "service이름.group" 을 추가해야합니다.

 

2. index.html

<Service On>버튼 클릭 시 'serviceOn' 서비스를 호출하는 코드입니다.

serviceOn'에 대한 설명은 아래의 service.js에 기술되어있습니다.

<script type="text/javascript">
    var bridge = new WebOSServiceBridge();

    function serviceOn() {
        console.log("call my service");
        const url = 'luna://com.heartbeat.app.service/serviceOn';
        const params = {};
        bridge.onservicecallback = (msg) => {
            console.log(msg);
            let res = JSON.parse(msg);
            document.getElementById("txt_msg").innerHTML = res.Response;
        };

        bridge.call(url, JSON.stringify(params));
    }
</script>
</head>
<body>
    <div>
        <h1 id="txt_msg">Hello, Web Application!!</h1>
        <button id="on" onclick="serviceOn()">Service On</button>
    </div>
</body>

 

전체코드

<!--
Copyright (c) 2020 LG Electronics Inc.

SPDX-License-Identifier: Apache-2.0
-->

<!DOCTYPE html>
<html>
<head>
<title>Example Web App</title>
<style type="text/css">
    body {
        width: 100%;
        height: 100%;
        background-color:#202020;
    }
    div {
        position:absolute;
        height:100%;
        width:100%;
        display: table;
    }
    h1 {
        display: table-cell;
        vertical-align: middle;
        text-align:center;
        color:#FFFFFF;
    }
</style>
<script type="text/javascript">
    var bridge = new WebOSServiceBridge();

    function serviceOn() {
        console.log("call my service");
        const url = 'luna://com.heartbeat.app.service/serviceOn';
        const params = {};
        bridge.onservicecallback = (msg) => {
            console.log(msg);
            let res = JSON.parse(msg);
            document.getElementById("txt_msg").innerHTML = res.Response;
        };

        bridge.call(url, JSON.stringify(params));
    }
</script>
</head>
<body>
    <div>
        <h1 id="txt_msg">Hello, Web Application!!</h1>
        <button id="on" onclick="serviceOn()">Service On</button>
    </div>
</body>
</html>

 

 

코드 설명 : service


1.  package.json

먼저 main 서비스를 만들고 다룰 js 파일을 service 폴더에 생성합니다. 본 포스트에서는 메인 서비스 파일명을 service(.js)로 설정하였습니다. 이후 package.json에서 main명을 service.js로 변경해줍니다. description명도 수정할 수 있습니다.   

{
  "name": "com.heartbeat.app.service",
  "version": "1.0.0",
  "description": "Heartbeat Demo service",
  "main": "service.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "BSD"
}

 

2.  service.js 

service.js는 위에서 언급한 것과 같이 메인 서비스를 다루는 파일입니다.

본 포스트에서는 serviceOn과 heartbeat 서비스만을 다루었기 때문에 service.js의 큰 흐름은 heartbeat의 구성 및 흐름과 같습니다.

 

 

2.1 serviceOn 등록 & heartbeat 구독

 

service.register을 이용하여 'serviceOn' 서비스를 등록합니다.

 

'serviceOn'은 3초마다 토스트 알림을 띄우는 행동을 5번 반복하도록 만든 서비스입니다. 그러나 webOS에서 서비스는 기본적으로 호출 후 약 5초의 실행 수명을 갖기 때문에, 'serviceOn' 메서드만 등록하고 실행할 경우 토스트 알림은 총 1번 밖에 뜨지 않게됩니다.

 

이 문제를 해결하기 위해 serviceOn 내에서 service.subscribe를 이용하여 heartbeat 서비스를 구독합니다. heartbeat에서 보내는 beat(response)를 수신하는 동안 serviceOn은 종료되지 않습니다.

 

120초가 지나면 모니터링을 종료하기 위해, heartbeatCnt가 heartbeatMax 값인 120을 초과하면 구독을 취소하도록 설정하였습니다.

service.register("serviceOn", (message) => {
    console.log(logHeader, message);

    const max = 5;
    let i = 0;
    let interval = setInterval(()=>{
        let url = "luna://com.webos.notification/createToast";
        let params = {
            message: `Hello! +${i}`
        };
    
        service.call(url, params, (m2)=>{
            console.log(logHeader, "SERVICE_METHOD_CALLED:com.webos.notification/createToast");
        });

        if(++i > max) {
            clearInterval(interval);
        }
    }, 3000);

    //heartbeat 구독
    const sub = service.subscribe('luna://com.heartbeat.app.service/heartbeat', {subscribe: true});
    const heartbeatMax = 120;
    let heartbeatCnt = 0;
    sub.addListener("response", function(msg) {
        console.log(JSON.stringify(msg.payload));
        if (++heartbeatCnt > heartbeatMax) {
            sub.cancel();
            setTimeout(function(){
                console.log(heartbeatMax+" responses received, exiting...");
                process.exit(0);
            }, 1000);
        }
    });

    message.respond({
        returnValue: true,
        Response: "My service has been started."
    });
});

 

 

2.2 heartbeat 등록

 

'heartbeat'는 'heartbeat'를 구독한 서비스를 대상으로 beat(response)를 송신하기 위해 만든 서비스입니다. 

 

service.register을 이용하여 'heartbeat'를 등록합니다. 

createHeartBeatInterval : setInterval을 이용하여 1초 간격으로 sendResponse를 반복합니다.

sendResponses : subscriptions(= heartbeat subscriber) 대상으로 beat를 송신합니다.

 

heartbeat는 subscriptions이 없을 경우 반복(heartbeatinterval = beat 송신)을 중단합니다.

// handle subscription requests
const subscriptions = {};
let heartbeatinterval;
let x = 1;
function createHeartBeatInterval() {
    if (heartbeatinterval) {
        return;
    }
    console.log(logHeader, "create_heartbeatinterval");
    heartbeatinterval = setInterval(function() {
        sendResponses();
    }, 1000);
}

// send responses to each subscribed client
function sendResponses() {
    console.log(logHeader, "send_response");
    console.log("Sending responses, subscription count=" + Object.keys(subscriptions).length);
    for (const i in subscriptions) {
        if (Object.prototype.hasOwnProperty.call(subscriptions, i)) {
            const s = subscriptions[i];
            s.respond({
                returnValue: true,
                event: "beat " + x
            });
        }
    }
    x++;
}

var heartbeat = service.register("heartbeat");
heartbeat.on("request", function(message) {
    console.log(logHeader, "SERVICE_METHOD_CALLED:/heartbeat");
    message.respond({event: "beat"}); // initial response 
    if (message.isSubscription) { 
        subscriptions[message.uniqueToken] = message; //add message to "subscriptions" 
        if (!heartbeatinterval) {
            createHeartBeatInterval();
        }
    } 
}); 
heartbeat.on("cancel", function(message) { 
    delete subscriptions[message.uniqueToken]; // remove message from "subscriptions" 
    var keys = Object.keys(subscriptions); 
    if (keys.length === 0) { // count the remaining subscriptions 
        console.log("no more subscriptions, canceling interval"); 
        clearInterval(heartbeatinterval);
        heartbeatinterval = undefined;
    } 
});

 

전체코드

// eslint-disable-next-line import/no-unresolved
const pkgInfo = require('./package.json');
const Service = require('webos-service');

const service = new Service(pkgInfo.name); // Create service by service name on package.json
const logHeader = "[" + pkgInfo.name + "]";

service.register("serviceOn", (message) => {
    console.log(logHeader, message);

    const max = 5;
    let i = 0;
    let interval = setInterval(()=>{
        let url = "luna://com.webos.notification/createToast";
        let params = {
            message: `Hello! +${i}`
        };
    
        service.call(url, params, (m2)=>{
            console.log(logHeader, "SERVICE_METHOD_CALLED:com.webos.notification/createToast");
        });

        if(++i > max) {
            clearInterval(interval);
        }
    }, 3000);

    //heartbeat 구독
    const sub = service.subscribe('luna://com.heartbeat.app.service/heartbeat', {subscribe: true});
    const heartbeatMax = 120;
    let heartbeatCnt = 0;
    sub.addListener("response", function(msg) {
        console.log(JSON.stringify(msg.payload));
        if (++heartbeatCnt > heartbeatMax) {
            sub.cancel();
            setTimeout(function(){
                console.log(heartbeatMax+" responses received, exiting...");
                process.exit(0);
            }, 1000);
        }
    });

    message.respond({
        returnValue: true,
        Response: "My service has been started."
    });
});

// handle subscription requests
const subscriptions = {};
let heartbeatinterval;
let x = 1;
function createHeartBeatInterval() {
    if (heartbeatinterval) {
        return;
    }
    console.log(logHeader, "create_heartbeatinterval");
    heartbeatinterval = setInterval(function() {
        sendResponses();
    }, 1000);
}

// send responses to each subscribed client
function sendResponses() {
    console.log(logHeader, "send_response");
    console.log("Sending responses, subscription count=" + Object.keys(subscriptions).length);
    for (const i in subscriptions) {
        if (Object.prototype.hasOwnProperty.call(subscriptions, i)) {
            const s = subscriptions[i];
            s.respond({
                returnValue: true,
                event: "beat " + x
            });
        }
    }
    x++;
}

var heartbeat = service.register("heartbeat");
heartbeat.on("request", function(message) {
    console.log(logHeader, "SERVICE_METHOD_CALLED:/heartbeat");
    message.respond({event: "beat"}); // initial response 
    if (message.isSubscription) { 
        subscriptions[message.uniqueToken] = message; //add message to "subscriptions" 
        if (!heartbeatinterval) {
            createHeartBeatInterval();
        }
    } 
}); 
heartbeat.on("cancel", function(message) { 
    delete subscriptions[message.uniqueToken]; // remove message from "subscriptions" 
    var keys = Object.keys(subscriptions); 
    if (keys.length === 0) { // count the remaining subscriptions 
        console.log("no more subscriptions, canceling interval"); 
        clearInterval(heartbeatinterval);
        heartbeatinterval = undefined;
    } 
});

 

실행화면