이번에 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