Google Cloud Messaging (GCM)은 개발자가 서버와 클라이언트 간에 메시지를 주고 받는것이 가능하게 해주는 무료 서비스입니다. 여기에는 서버로부터 클라이언트 앱으로 전송하는 다운스트림 메시지(Downstream Message)와 클라이언트 앱으로부터 서버로 전송하는 업스트림 메시지(Upstream Message)가 포함됩니다.
예를 들어 “새로운 메일이 도착했습니다” 노티피케이션과 같은 서버로부터의 새로운 정보를 클라이언트에 알려줄 수 있는 가벼운 형태의 다운스트림 메시지가 있습니다. 이러한 인스턴트 메시지를 사용하는 경우 GCM 메시지를 이용하면 최대 4KB의 페이로드를 클라이언트 앱에 전송할 수 있습니다. GCM 서비스는 수신 또는 송신을 하게 될 클라이언트 앱으로부터의 메시지 전송에 대한 큐잉(Queueing)을 포함한 모든 측면을 처리합니다.
GCM를 구현하는데에는 구글 커넥션 서버와 HTTP 또는 XMPP 프로토콜을 통해 커넥션 서버와 통신을 할 환경을 갖춘 앱 서버, 그리고 클라이언트 앱이 포함됩니다.
이러한 각각의 컴포넌트들은 다음과 같은 방식으로 통신하게 됩니다.
구글 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 : 메시지 수신을 받는 클라이언트 앱이 있을 때 플랫폼 별로 다음과 같이 다른 구현이 필요합니다.
이번에 Spring 프로젝트를 진행하면서 GCM 서버를 CCS 방식으로 구현하여 보았습니다. CCS 방식은 구글 서버와 지속적인 커넥션을 유지하며 비동기 양방향 통신을 하는 XMPP방식의 엔드포인트를 의미합니다. XMPP의 비동기적인 특성에 의해 적은 리소스로 더 많은 메시지를 발송할 수 있으며 양방향 통신을 지원하기 때문에 서버에서 디바이스로 메시지를 보내는것뿐 아니라 반대로 디바이스에서 서버로 응답 메시지를 되돌려 보낼 수도 있습니다. 이 때에 디바이스는 메시지 수신을 위해 연결했던 커넥션을 재활용하므로 배터리를 아낄 수 있습니다. 이렇게 좋은 장점들이 있지만 저는 우선 단방향 통신이면 충분한것 같습니다.
1. gradle에 필요한 라이브러리 추가하기
프로젝트가 gradle 프로젝트일경우 다음과 같이 smack 라이브러리를 dependencies 에 추가합니다. gradle 프로젝트가 아닐경우 적당히 라이브러리를 다운받아 프로젝트에 추가하시면 됩니다.
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를 이용하여 메시지를 발송하시면 됩니다.