クロスプラットフォームプッシュ通知の概要:はじめに

良い一日!私の名前はウラジミールストリヤロフです。私はDomKlikのクライアントコミュニケーションチームのバックエンド開発者です。この記事では、クロスプラットフォームのプッシュ通知を実装する方法について説明します。これについてはすでに多くのことが書かれていますが、実装プロセスで直面しなければならないニュアンスのいくつかについてお話ししたいと思います。何が起こっているのかをよりよく理解するために、プッシュ通知を受け入れることができる小さなWebアプリケーションも作成します。





まず、プッシュ通知をどこに送信するかを理解する必要があります。私たちの場合、これはウェブサイト、iOSアプリ、Androidアプリです。



Webプッシュ通知から始めましょう。それらを受信するために、ブラウザーはプッシュサーバーに接続し、自身を識別して、Service Workerへの通知を受信します(イベントがトリガーされますpush)。ここでのニュアンスは、各ブラウザーに独自のプッシュサービスがあることです。





, - IETF (https://datatracker.ietf.org/wg/webpush/documents/), API , .



Android. :





iOS. Android, Apple — Apple Push Notification service (APNs).



: , API ? , , Firebase Cloud Messaging, Android, - APNs. : Huawei Google Apps Huawei Push Kit, Firebase Cloud Messaging.





, :



  1. - — .
  2. , .
  3. .




- Firebase . Firebase -. HTTP- .



:



simple_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>   </title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <button onclick="enableNotifications()"> </button>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
    </div>
    <script>
        async function enableNotifications() {
            // Insert your firebase project config here
            const firebaseConfig = {};

            const app = firebase.initializeApp(firebaseConfig);
            const messaging = app.messaging();

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token
        }
    </script>
    </body>
</html>


-. /firebase-messaging-sw.js. .



, , . API ( ). :



curl -X POST 'https://fcm.googleapis.com/fcm/send' \
-H 'Authorization: key=<fcm server key>' \
-H 'Content-Type: application/json' \
-d '{
 "to" : "<  >",
 "notification" : {
     "body" : "Body of Your Notification",
     "title": "Title of Your Notification"
 }
}'


:





, : - , (.. ). .



, . setBackgroundMessageHandler. -, :



messaging.setBackgroundMessageHandler((payload) => {
  console.log('Message received. ', payload);
  // ...
});


-, … , . ? :



Note: If you set notification fields in your message payload, your setBackgroundMessageHandler callback is not called, and instead the SDK displays a notification based on your payload.

notification . , .



, , . firebase-messaging-sw.js:



firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })
}


json js-, , . waitUntil : , - onPush.





:



user_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>   </title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <form onsubmit="enableNotifications(this); return false" action="#">
        User ID <input type="number" name="userID" required/>
        <input type="submit" value=" "/>
    </form>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
        <button onclick="logout()"></button>
    </div>
    <script>
        // Insert your firebase project config here
        const firebaseConfig = {};

        const app = firebase.initializeApp(firebaseConfig);
        const messaging = app.messaging(); // this fails if browser not supported

        async function getMe() {
            const resp = await fetch(`${window.location.origin}/api/v1/users/me`, {
                credentials: "include",
            });

            if (resp.status === 401) {
                return null;
            }
            if (!resp.ok) {
                throw `unexpected status code ${resp.status}`
            }

            return await resp.json();
        }

        async function sendToken(token) {
            const me = await getMe();
            if (me === null) {
                console.error("unauthorized on send token");
                return;
            }

            window.localStorage.getItem("push-token-user");

            const resp = await fetch(`${window.location.origin}/api/v1/tokens`, {
                method: "POST",
                body: JSON.stringify({
                    token: {token: token, platform: "web"}
                }),
                credentials: "include",
            })
            if (!resp.ok) {
                console.error("send token failed");
                return;
            }

            // put current user to local storage for comparison
            window.localStorage.setItem("push-token-user", JSON.stringify(me));
        }

        getMe().
            then(me => {
                if (!me) {
                    // if user not authorized we must invalidate firebase registration
                    // to prevent receiving pushes for unauthorized user
                    // this may happen i.e. if 'deleteToken' failed on logout
                    console.log(`user unauthorized, invalidate fcm registration`);
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    return null;
                }

                // if user authorized and it's not user that received push token earlier
                // we also must invalidate token to prevent receiving pushes for wrong user
                // this may happen if i.e. user not logged out explicitly
                let pushTokenUser = window.localStorage.getItem("push-token-user");
                if (pushTokenUser && JSON.parse(pushTokenUser).id !== me.id) {
                    console.log("token for wrong user, invalidate fcm registration");
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    pushTokenUser = null;
                }

                // if user authorized and permission granted but token wasn't send we should re-send it
                if (!pushTokenUser && Notification.permission === "granted") {
                    console.log("token not sent to server while notification permission granted");
                    messaging.getToken().then(sendToken);
                }
            }).
            catch(e => console.log("get me error", e))

        // according to sources of firebase-js-sdk source code registration token refreshed once a week
        messaging.onTokenRefresh(async () => {
            const newToken = await messaging.getToken();
            pushTokenValue.innerText = newToken;
            console.log(`updated token to ${newToken}`)
            await sendToken(newToken)
        })

        async function enableNotifications(form) {
            const loginResponse = await fetch(`${window.location.origin}/api/v1/users/login`, {
                method: "POST",
                body: JSON.stringify({
                    id: Number(form.elements.userID.value),
                })
            })
            if (!loginResponse.ok) {
                alert("login failed");
                return;
            }

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
                return;
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token

            await sendToken(token)
        }

        async function logout() {
            const messaging = firebase.messaging();
            await messaging.deleteToken();
            console.log(`deleted token from firebase`)
            window.document.getElementById("pushTokenLayer").setAttribute("hidden", "");
            await fetch(`${window.location.origin}/api/v1/users/logout`, {
                method: "POST",
                credentials: "include",
            })
        }
    </script>
    </body>
</html>


, Go. , :



inmemory.go
type MemoryStorage struct {
    mu          sync.RWMutex
    userTokens  map[uint64][]Token
    tokenOwners map[string]uint64
}

func NewMemoryStorage() *MemoryStorage {
    return &MemoryStorage{
        userTokens:  map[uint64][]Token{},
        tokenOwners: map[string]uint64{},
    }
}

type Token struct {
    Token    string `json:"token"`
    Platform string `json:"platform"`
}

func (ms *MemoryStorage) SaveToken(ctx context.Context, userID uint64, token Token) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    owner, ok := ms.tokenOwners[token.Token]
    // if old user comes with some token it's ok
    if owner == userID {
        return nil
    }
    // if new user come with existing token we
    // should change it's owner to prevent push target mismatch
    if ok {
        ms.deleteTokenFromUser(token.Token, owner)
    }

    ut := ms.userTokens[userID]
    ut = append(ut, token)
    ms.userTokens[userID] = ut

    ms.tokenOwners[token.Token] = userID

    return nil
}

func (ms *MemoryStorage) deleteTokenFromUser(token string, userID uint64) {
    ut := ms.userTokens[userID]
    for i, t := range ut {
        if t.Token == token {
            ut[i], ut[len(ut)-1] = ut[len(ut)-1], Token{}
            ut = ut[:len(ut)-1]
            break
        }
    }
    ms.userTokens[userID] = ut
}

func (ms *MemoryStorage) UserTokens(ctx context.Context, userID uint64) ([]Token, error) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()

    tokens := ms.userTokens[userID]
    ret := make([]Token, len(tokens))
    copy(ret, tokens)

    return ret, nil
}

func (ms *MemoryStorage) DeleteTokens(ctx context.Context, tokens []string) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    for _, token := range tokens {
        user, ok := ms.tokenOwners[token]
        if !ok {
            return nil
        }

        ms.deleteTokenFromUser(token, user)
    }
    return nil
}


.



:



  • , - . - / .
  • -. Firebase , , .
  • - . .


, ( ):



  • - , , , . firebase-js-sdk, , onTokenRefresh .
  • -. , Firebase . .
  • . .. , . . - , . , - . : .
  • , . : , ( Android/iOS , — ), .


, , . … ?





, Huawei . . , — HTTP- . , Firebase, Huawei : .



: ( UUID) . HTTP-, . firebase-messaging-sw.js:



firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {}, data = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })

    if (data.id) {
        await fetch(`${self.location.origin}/api/v1/notifications/${data.id}/confirm`, { method: "POST" })
    }
}


, . setBackgroundMessageHandler? , , Firebase ( Huawei) ( API) , , ( notification) data-. , , data- , .



- , firebase-js-sdk -, Android . Android data notification, .



APNs mutable-content 1, , , HTTP-. , - iOS , .



: data- , - , . , , Telegram , .



: , , , 15 , . , , TTL .



. , :



  • Android ( Huawei) — 40 %
  • Web — 50 %
  • iOS — 70 %


Huawei . , , , , ..



:





GitHub.



, -, , , -, .




All Articles