이번에 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); } }