Tag Archives: GCM

Google Cloud Messaging (GCM) 개요

gcm-versatile-messaging-targets

Google Cloud Messaging (GCM)은 개발자가 서버와 클라이언트 간에 메시지를 주고 받는것이 가능하게 해주는 무료 서비스입니다. 여기에는 서버로부터 클라이언트 앱으로 전송하는 다운스트림 메시지(Downstream Message)와 클라이언트 앱으로부터 서버로 전송하는 업스트림 메시지(Upstream Message)가 포함됩니다.

예를 들어 “새로운 메일이 도착했습니다” 노티피케이션과 같은 서버로부터의 새로운 정보를 클라이언트에 알려줄 수 있는 가벼운 형태의 다운스트림 메시지가 있습니다. 이러한 인스턴트 메시지를 사용하는 경우 GCM 메시지를 이용하면 최대 4KB의 페이로드를 클라이언트 앱에 전송할 수 있습니다. GCM 서비스는 수신 또는 송신을 하게 될 클라이언트 앱으로부터의 메시지 전송에 대한 큐잉(Queueing)을 포함한 모든 측면을 처리합니다.

GCM를 구현하는데에는 구글 커넥션 서버와 HTTP 또는 XMPP 프로토콜을 통해 커넥션 서버와 통신을 할 환경을 갖춘 앱 서버, 그리고 클라이언트 앱이 포함됩니다.

스크린샷 2016-02-24 오후 8.59.40

이러한 각각의 컴포넌트들은 다음과 같은 방식으로 통신하게 됩니다.

  • 구글 GCM 커넥션 서버는 당신의 앱 서버로부터 다운스트림 메시지를 받아서 클라이언트 앱으로 전송을 하게 됩니다. XMPP 커넥션 서버의 경우 클라이언트 앱으로부터 업스트림 메시지를 받아서 당신의 앱 서버로 전송할 수도 있습니다. 더 많은 정보가 필요할 경우 [GCM 커넥션 서버에 대해]를 참고해 주세요.
  • 당신의 앱 서버에서는 GCM 커넥션 서버와 통신을 할 HTTP 와 XMPP 프로토콜을 구현합니다. 앱 서버는 GCM 커넥션 서버에 다운스트림 메시지를 발송하고, 커넥션 서버는 이 메시지를 큐에 담에 보관합니다. 그리고 클라이언트 앱에 다시 전송하게 됩니다. 만약 당신이 XMPP를 구현하였다면 당신의 앱 서버는 클라이언트 앱으로부터 메시지를 수신받는 것이 가능합니다.
  • 클라이언 앱은 GCM이 활성화 된 앱 앱을 의미합니다. GCM 메시지를 수신하기 위해 당신의 앱은 반드시 GCM에 등록되어야 하며 등록 토큰(Registration Token)이라고 불리는 유니크한 식별자를 받아야 합니다.

GCM과 관련된 기본적인 용어와 컨셉은 다음과 같습니다. 크게 다음과 같은 카테고리로 나뉘어질 수 있습니다.

  • 컴포넌트(Components) : GCM에서 주요한 역할을 담당할 엔티티
  • 자격(Credentials) : 메시지가 올바른 장소에 전달되도록 하는 모든 부분에서 인증을 위해 사용되는 GCM에서 사용되는 ID와 토큰

컴포넌트에는 크게 3가지로 다음과 같은 요소가 있습니다.

  • GCM 커넥션 서버 : 앱 서버와 클라이언트 앱간에 메시지를 전송하는데 관여하는 구글이 관리하는 서버
  • 클라이언트 앱 : 당신의 앱 서버와 통신하고자 하는 GCM이 활성화 된 클라이언트 앱
  • 앱 서버 : GCM 구현의 일부가 되도록 개발 되어야 하는 앱 서버. 이 앱 서버는 GCM 커넥션 서버를 통해 클라이언트 앱에 데이터를 전송합니다. 만약 당신의 앱 서버가 XMPP 프로토콜을 구현하였다면 클라이언트 앱으로부터 메시지를 수신받는것도 가능합니다.

자격(Credentials)에는 다음과 같은 4가지 요소가 있습니다.

  • Sender ID : 당신이 구글 개발자 콘솔에서 API 프로젝트를 설정함으로써 발급되는 유니크한 숫자로 된 값. 이 Sender ID는 앱서버가 클라이언트 앱에 메시지를 발송하는것이 허용되는지 여부를 확인하는데에 사용됩니다.
  • API Key : API Key는 당신의 앱 서버에 저장되며 이 앱서버가 구글 서비스에 접근 가능하도록 인증하는데에 사용됩니다. HTTP 환경에서 API Key는 POST로 요청으로 발송되는 발송 메시지의 헤더에 포함되어야 합니다. XMPP 환경에서는 커넥션을 맺을 때 사용되는 비밀번호로 사용되는 인증과 같은 SASL PLAIN 인증을 사용합니다. 이 API Key는 당신의 클라이언트 코드 어디에도 포함되어서는 안됩니다.
  • Application ID : 메시지 수신을 받는 클라이언트 앱이 있을 때 플랫폼 별로 다음과 같이 다른 구현이 필요합니다.
    • Android : 앱 Manifest에 등록되어있는 패키지명(Package Name)을 사용합니다.
    • iOS : 앱의 번들 아이디(Bundle Identifier)를 사용합니다.
    • Chrome : 크롬 익스텐션의 이름을 사용합니다.
  • Registration Token : 클라이언트 앱이 메시지 수신을 가능하게 하기 위해 GCM 커넥션 서버로부터 발급 받는 ID. 이 Registration Token은 보안을 신경써서 보관되어야 합니다.

GCM이 동작하는 라이프사이클 과정은 다음과 같습니다.

  • GCM을 사용가능하도록 등록합니다. 메시지 수신을 하고자 하는 클라이언트 앱을 등록합니다. 더 자세한 내용은 [클라이언트 앱 등록]을 참고해주세요.
  • 다운스트림 메시지 발송 및 수신하기.
    • 메시지를 발송합니다. 앱 서버는 클라이언트 앱에 메시지를 발송합니다.
      • 앱서버는 GCM 커넥션 서버에 메시지를 발송합니다.
      • 만약 디바이스가 오프라인이라면 GCM 커넥션 서버는 수신한 메시지를 큐에 담고 저장해 둡니다.
      • 디바이스가 온라인 될 때, GCM 커넥션 서버는 이 디바이스에 메시지를 발송합니다.
      • 디바이스에서는, 플랫폼별로 적절하게 구현되어있는 클라이언트 앱이 메시지를 수신합니다.
    • 메시지를 수신합니다. 클라이언트 앱은 GCM 커넥션 서버로부터 메시지를 수신합니다.
  • 업스트림 메시지 발송 및 수신하기. 이 기능은 XMPP 커넥션 서버를 사용할 경우에만 사용 가능합니다.
    • 메시지를 발송합니다. 클라이언트 앱이 앱 서버에 메시지를 발송합니다.
      • 디바이스에서, 클라이언트 앱은 XMPP 커넥션 서버에 메시지를 발송합니다.
      • 만약 서버가 연결이 되지 않을 경우 XMPP 커넥션 서버는 메시지를 큐에 담고 저장해 둡니다.
      • 서버가 다시 연결 될 때, XMPP 커넥션 서버는 앱 서버에 메시지를 발송합니다.
    • 메시지를 수신합니다. 앱 서버는 XMPP 커넥션 서버로 부터 메시지를 수신받고 다음과 같은 과정을 거칩니다.
      • 메시지의 헤더를 파싱하여 클라이언트 앱의 발송자 정보를 검증합니다.
      • 메시지 수신을 인정하는 “ack” 신호를 XMPP 커넥션 서버에 발송합니다.
      • 클라이언트 앱에서 정의한대로 메시지의 페이로드를 파싱하여 해당 정보를 사용합니다.

메시지에 대한 좀 더 상세한 설명과 사용가능한 옵션에 대해 알아보실려면 [Google Cloud Messaging (GCM) 메시지 컨셉과 옵션]를 참고해주세요.

참고 : https://developers.google.com/cloud-messaging/gcm

Spring에서 GCM CCS (XMPP) 서버 구현하기

spring_title

이번에 Spring 프로젝트를 진행하면서 GCM 서버를 CCS 방식으로 구현하여 보았습니다. CCS 방식은 구글 서버와 지속적인 커넥션을 유지하며 비동기 양방향 통신을 하는 XMPP방식의 엔드포인트를 의미합니다. XMPP의 비동기적인 특성에 의해 적은 리소스로 더 많은 메시지를 발송할 수 있으며 양방향 통신을 지원하기 때문에 서버에서 디바이스로 메시지를 보내는것뿐 아니라 반대로 디바이스에서 서버로 응답 메시지를 되돌려 보낼 수도 있습니다. 이 때에 디바이스는 메시지 수신을 위해 연결했던 커넥션을 재활용하므로 배터리를 아낄 수 있습니다. 이렇게 좋은 장점들이 있지만 저는 우선 단방향 통신이면 충분한것 같습니다.

1. gradle에 필요한 라이브러리 추가하기

프로젝트가 gradle 프로젝트일경우 다음과 같이 smack 라이브러리를 dependencies 에 추가합니다. gradle 프로젝트가 아닐경우 적당히 라이브러리를 다운받아 프로젝트에 추가하시면 됩니다.

dependencies {
  ...
  compile "org.igniterealtime.smack:smack-core:4.0.4"
  compile "org.igniterealtime.smack:smack-tcp:4.0.4"
}

2. smack 클라이언트 클래스 작성

smack을 이용하여 구글 서버와 CCS 방식으로 통신하기 위해 클라이언트 클래스의 작성이 필요합니다. 깊게 생각할 것 없이 구글에서 제공하는 코드를 사용합니다.

public class SmackCcsClient {

    private static final Logger logger = Logger.getLogger("SmackCcsClient");

    private static final String GCM_SERVER = "gcm.googleapis.com";
    private static final int GCM_PORT = 5235;

    private static final String GCM_ELEMENT_NAME = "gcm";
    private static final String GCM_NAMESPACE = "google:mobile:data";

    static {

        ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
                new PacketExtensionProvider() {
                    @Override
                    public PacketExtension parseExtension(XmlPullParser parser) throws
                            Exception {
                        String json = parser.nextText();
                        return new GcmPacketExtension(json);
                    }
                });
    }

    private XMPPConnection connection;

    /**
     * Indicates whether the connection is in draining state, which means that it
     * will not accept any new downstream messages.
     */
    protected volatile boolean connectionDraining = false;

    /**
     * Sends a downstream message to GCM.
     *
     * @return true if the message has been successfully sent.
     */
    public boolean sendDownstreamMessage(String jsonRequest) throws
            NotConnectedException {
        if (!connectionDraining) {
            send(jsonRequest);
            return true;
        }
        logger.info("Dropping downstream message since the connection is draining");
        return false;
    }

    /**
     * Returns a random message id to uniquely identify a message.
     *
     * <p>Note: This is generated by a pseudo random number generator for
     * illustration purpose, and is not guaranteed to be unique.
     */
    public String nextMessageId() {
        return "m-" + UUID.randomUUID().toString();
    }

    /**
     * Sends a packet with contents provided.
     */
    protected void send(String jsonRequest) throws NotConnectedException {
        Packet request = new GcmPacketExtension(jsonRequest).toPacket();
        connection.sendPacket(request);
    }

    /**
     * Handles an upstream data message from a device application.
     *
     * <p>This sample echo server sends an echo message back to the device.
     * Subclasses should override this method to properly process upstream messages.
     */
    protected void handleUpstreamMessage(Map<String, Object> jsonObject) {
        // PackageName of the application that sent this message.
        String category = (String) jsonObject.get("category");
        String from = (String) jsonObject.get("from");
        @SuppressWarnings("unchecked")
        Map<String, String> payload = (Map<String, String>) jsonObject.get("data");
        payload.put("ECHO", "Application: " + category);

        // Send an ECHO response back
        String echo = createJsonMessage(from, nextMessageId(), payload,
                "echo:CollapseKey", null, false);

        try {
            sendDownstreamMessage(echo);
        } catch (NotConnectedException e) {
            logger.log(Level.WARNING, "Not connected anymore, echo message is not sent", e);
        }
    }

    /**
     * Handles an ACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle ACKs.
     */
    protected void handleAckReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleAckReceipt() from: " + from + ", messageId: " + messageId);
    }

    /**
     * Handles a NACK.
     *
     * <p>Logs a INFO message, but subclasses could override it to
     * properly handle NACKs.
     */
    protected void handleNackReceipt(Map<String, Object> jsonObject) {
        String messageId = (String) jsonObject.get("message_id");
        String from = (String) jsonObject.get("from");
        logger.log(Level.INFO, "handleNackReceipt() from: " + from + ", messageId: " + messageId);
    }

    protected void handleControlMessage(Map<String, Object> jsonObject) {
        logger.log(Level.INFO, "handleControlMessage(): " + jsonObject);
        String controlType = (String) jsonObject.get("control_type");
        if ("CONNECTION_DRAINING".equals(controlType)) {
            connectionDraining = true;
        } else {
            logger.log(Level.INFO, "Unrecognized control type: %s. This could happen if new features are " + "added to the CCS protocol.",
            controlType);
        }
    }

    /**
     * Creates a JSON encoded GCM message.
     *
     * @param to RegistrationId of the target device (Required).
     * @param messageId Unique messageId for which CCS will send an
     *         "ack/nack" (Required).
     * @param payload Message content intended for the application. (Optional).
     * @param collapseKey GCM collapse_key parameter (Optional).
     * @param timeToLive GCM time_to_live parameter (Optional).
     * @param delayWhileIdle GCM delay_while_idle parameter (Optional).
     * @return JSON encoded GCM message.
     */
    public static String createJsonMessage(String to, String messageId,
                                           Map<String, String> payload, String collapseKey, Long timeToLive,
                                           Boolean delayWhileIdle) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("to", to);
        if (collapseKey != null) {
            message.put("collapse_key", collapseKey);
        }
        if (timeToLive != null) {
            message.put("time_to_live", timeToLive);
        }
        if (delayWhileIdle != null && delayWhileIdle) {
            message.put("delay_while_idle", true);
        }
        message.put("message_id", messageId);
        message.put("data", payload);
        return JSONValue.toJSONString(message);
    }

    /**
     * Creates a JSON encoded ACK message for an upstream message received
     * from an application.
     *
     * @param to RegistrationId of the device who sent the upstream message.
     * @param messageId messageId of the upstream message to be acknowledged to CCS.
     * @return JSON encoded ack.
     */
    protected static String createJsonAck(String to, String messageId) {
        Map<String, Object> message = new HashMap<String, Object>();
        message.put("message_type", "ack");
        message.put("to", to);
        message.put("message_id", messageId);
        return JSONValue.toJSONString(message);
    }

    /**
     * Connects to GCM Cloud Connection Server using the supplied credentials.
     *
     * @param senderId Your GCM project number
     * @param apiKey API Key of your project
     */
    public void connect(long senderId, String apiKey)
            throws XMPPException, IOException, SmackException {
        ConnectionConfiguration config =
                new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
        config.setSecurityMode(SecurityMode.enabled);
        config.setReconnectionAllowed(true);
        config.setRosterLoadedAtLogin(false);
        config.setSendPresence(false);
        config.setSocketFactory(SSLSocketFactory.getDefault());
        config.setDebuggerEnabled(false);

        connection = new XMPPTCPConnection(config);
        connection.connect();

        connection.addConnectionListener(new LoggingConnectionListener());

        // Handle incoming packets
        connection.addPacketListener(new PacketListener() {

            @Override
            public void processPacket(Packet packet) {
                logger.log(Level.INFO, "Received: " + packet.toXML());
                Message incomingMessage = (Message) packet;
                GcmPacketExtension gcmPacket =
                        (GcmPacketExtension) incomingMessage.
                                getExtension(GCM_NAMESPACE);
                String json = gcmPacket.getJson();
                try {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> jsonObject =
                            (Map<String, Object>) JSONValue.parseWithException(json);

                    // present for "ack"/"nack", null otherwise
                    Object messageType = jsonObject.get("message_type");

                    if (messageType == null) {
                        // Normal upstream data message
                        handleUpstreamMessage(jsonObject);

                        // Send ACK to CCS
                        String messageId = (String) jsonObject.get("message_id");
                        String from = (String) jsonObject.get("from");
                        String ack = createJsonAck(from, messageId);
                        send(ack);
                    } else if ("ack".equals(messageType.toString())) {
                        // Process Ack
                        handleAckReceipt(jsonObject);
                    } else if ("nack".equals(messageType.toString())) {
                        // Process Nack
                        handleNackReceipt(jsonObject);
                    } else if ("control".equals(messageType.toString())) {
                        // Process control message
                        handleControlMessage(jsonObject);
                    } else {
                        logger.log(Level.WARNING,
                                "Unrecognized message type (%s)",
                                messageType.toString());
                    }
                } catch (ParseException e) {
                    logger.log(Level.SEVERE, "Error parsing JSON " + json, e);
                } catch (Exception e) {
                    logger.log(Level.SEVERE, "Failed to process packet", e);
                }
            }
        }, new PacketTypeFilter(Message.class));

        // Log all outgoing packets
        connection.addPacketInterceptor(new PacketInterceptor() {
            @Override
            public void interceptPacket(Packet packet) {
                logger.log(Level.INFO, "Sent: {0}", packet.toXML());
            }
        }, new PacketTypeFilter(Message.class));

        connection.login(senderId + "@gcm.googleapis.com", apiKey);
    }

    /**
     * XMPP Packet Extension for GCM Cloud Connection Server.
     */
    private static final class GcmPacketExtension extends DefaultPacketExtension {

        private final String json;

        public GcmPacketExtension(String json) {
            super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
            this.json = json;
        }

        public String getJson() {
            return json;
        }

        @Override
        public String toXML() {
            return String.format("<%s xmlns=\"%s\">%s</%s>",
                    GCM_ELEMENT_NAME, GCM_NAMESPACE,
                    StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
        }

        public Packet toPacket() {
            Message message = new Message();
            message.addExtension(this);
            return message;
        }
    }

    private static final class LoggingConnectionListener
            implements ConnectionListener {

        @Override
        public void connected(XMPPConnection xmppConnection) {
            logger.info("Connected.");
        }

        @Override
        public void authenticated(XMPPConnection xmppConnection) {
            logger.info("Authenticated.");
        }

        @Override
        public void reconnectionSuccessful() {
            logger.info("Reconnecting..");
        }

        @Override
        public void reconnectionFailed(Exception e) {
            logger.log(Level.INFO, "Reconnection failed.. ", e);
        }

        @Override
        public void reconnectingIn(int seconds) {
            logger.log(Level.INFO, "Reconnecting in %d secs", seconds);
        }

        @Override
        public void connectionClosedOnError(Exception e) {
            logger.info("Connection closed on error.");
        }

        @Override
        public void connectionClosed() {
            logger.info("Connection closed.");
        }
    }
}

3. Spring Sender 컴포넌트 작성

위에서 작성한 SmackCssClient는 한번 초기화 되면 구글 서버와 연결을 맺고 커넥션을 지속합니다. 이 하나의 커넥션으로 다수의 메시지를 보낼 수 있으며 외부의 요인에 의해 연결이 끊어질 경우 자동으로 다시 재연결을 하게 됩니다. 그러므로 Spring에서 컴포넌트로 등록하여 이 객체(빈)의 관리를 컨테이너에 맞기고 생성 직후 커넥션을 맺고 비동기 매소드인 send를 통해 딜레이 없이 메시지를 발송할 수 있도록 하였습니다. (@Async가 사용가능하도록 구현되어있어야 합니다)

페이로드를 만드는 부분에서 키로 msg를 사용하는데 그것은 현재의 프로젝트에 맞춰 적절히 구현하시면 됩니다.

@Component
public class GcmCcsSender {

    private static final Logger LOG = LoggerFactory.getLogger(GcmCcsSender.class);

    private static final long senderId = {SENDER_ID};
    private static final String password = "{PASSWORD}";
    private SmackCcsClient mCssClient = new SmackCcsClient();

    @PostConstruct
    public void init() {
        try {
            mCssClient.connect(senderId, password);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }

    @Async
    public void send(String registrationId, String message) {
        try {
            Map<String, String> payload = new HashMap<String, String>();
            payload.put("msg", message);

            String jsonMessage = mCssClient.createJsonMessage(registrationId, mCssClient.nextMessageId(), payload, "", 10000L, true);
            mCssClient.sendDownstreamMessage(jsonMessage);
        } catch (Exception e) {
            LOG.error(e.getLocalizedMessage());
        }
    }
}

 4. 메시지 발송하기

다음은 메시지 발송을 위한 단순한 예시입니다. 적당한 방법으로 서버에서 수집한 사용자들의 registrationId를 이용하여 메시지를 발송하시면 됩니다.

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private GcmCcsSender mGcmCcsSender;

    @RequestMapping(value = "/{registrationId}", method = RequestMethod.GET)
    public void monsters(@PathVariable("registrationId") String registrationId) {
        String message = "TEST MESSAGE";

        mGcmCcsSender.send(registrationId, message);
    }
}

참고 : http://developer.android.com/google/gcm/ccs.html