
cn.ipokerface.aps.handler.ApnsClientChannelHandler Maven / Gradle / Ivy
Show all versions of apple-pns-java Show documentation
package cn.ipokerface.aps.handler;
import cn.ipokerface.aps.Constant;
import cn.ipokerface.aps.notification.Notification;
import cn.ipokerface.aps.notification.NotificationFuture;
import cn.ipokerface.aps.response.NotificationError;
import cn.ipokerface.aps.response.NotificationResponse;
import cn.ipokerface.aps.response.ResponseCode;
import com.alibaba.fastjson.JSON;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPromise;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpScheme;
import io.netty.handler.codec.http2.*;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.collection.IntObjectHashMap;
import io.netty.util.concurrent.GenericFutureListener;
import io.netty.util.concurrent.Promise;
import io.netty.util.concurrent.PromiseCombiner;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* Created by PokerFace
* Create Date 2020-08-24.
* Email: [email protected]
* Version 1.0.0
*
* Description:
*/
public class ApnsClientChannelHandler extends Http2ConnectionHandler implements Http2FrameListener, Http2Connection.Listener {
private static final Logger logger = LoggerFactory.getLogger(ApnsClientChannelHandler.class);
private static final IOException STREAMS_EXHAUSTED_EXCEPTION =
new IOException("HTTP/2 streams exhausted; closing connection.");
private static final IOException STREAM_CLOSED_BEFORE_REPLY_EXCEPTION =
new IOException("Stream closed before a reply was received");
private Throwable connectionErrorCause;
private final Map> promiseStreamId = new IntObjectHashMap<>();
private String authority;
private Duration idleInterval;
private ScheduledFuture idleFuture;
private String defaultTopic;
private Http2Connection.PropertyKey responseHeadersPropertyKey;
private Http2Connection.PropertyKey responsePromisePropertyKey;
private Http2Connection.PropertyKey streamErrorCausePropertyKey;
public ApnsClientChannelHandler(Http2ConnectionDecoder decoder,
Http2ConnectionEncoder encoder,
Http2Settings initialSettings,
String authority,
String defaultTopic,
Duration idleInterval) {
super(decoder, encoder, initialSettings);
this.authority = authority;
if (idleInterval == null) {
this.idleInterval = Duration.ofSeconds(30);
} else{
this.idleInterval = idleInterval.dividedBy(2);
}
this.defaultTopic = defaultTopic;
this.connection().addListener(this);
this.responseHeadersPropertyKey = this.connection().newKey();
this.responsePromisePropertyKey = this.connection().newKey();
this.streamErrorCausePropertyKey = this.connection().newKey();
}
@Override
public void channelInactive(final ChannelHandlerContext context) throws Exception {
super.channelInactive(context);
for (final NotificationFuture, ?> future : this.promiseStreamId.values()) {
future.completeExceptionally(STREAM_CLOSED_BEFORE_REPLY_EXCEPTION);
}
this.promiseStreamId.clear();
}
@Override
public void userEventTriggered(final ChannelHandlerContext context, final Object event) throws Exception {
if (event instanceof IdleStateEvent) {
logger.trace("Sending ping due to inactivity.");
this.encoder().writePing(context, false, System.currentTimeMillis(), context.newPromise()).addListener(
(GenericFutureListener) future -> {
if (!future.isSuccess()) {
logger.debug("Failed to write PING frame.", future.cause());
future.channel().close();
}
});
this.idleFuture = context.channel().eventLoop().schedule(() -> {
logger.debug("Closing channel due to ping timeout.");
context.channel().close();
}, idleInterval.toMillis(), TimeUnit.MILLISECONDS);
this.flush(context);
}
super.userEventTriggered(context, event);
}
@Override
public void exceptionCaught(final ChannelHandlerContext context, final Throwable cause) {
// Always try to fail the "channel ready" promise if we catch an exception; in some cases, these may happen
// after a connection has already become ready, in which case the failure attempt will have no effect.
getChannelReadyPromise(context.channel()).tryFailure(cause);
}
@Override
public void write(ChannelHandlerContext context, Object message, ChannelPromise promise) throws Exception {
// super.write(ctx, message, promise);
if (message instanceof NotificationFuture) {
final NotificationFuture, ?> pushNotificationFuture = (NotificationFuture, ?>) message;
promise.addListener(future -> {
if (!future.isSuccess()) {
logger.trace("Failed to write push notification.", future.cause());
pushNotificationFuture.completeExceptionally(future.cause());
}
});
this.writeNotification(context, pushNotificationFuture, promise);
} else {
// This should never happen, but in case some foreign debris winds up in the pipeline, just pass it through.
logger.error("Unexpected object in pipeline: {}", message);
context.write(message, promise);
}
}
/**
*
* @param context
* @param streamId
*/
private void retryNotificationFromStream(final ChannelHandlerContext context, final int streamId) {
final Http2Stream stream = this.connection().stream(streamId);
final NotificationFuture, ?> responseFuture = stream.removeProperty(this.responsePromisePropertyKey);
final ChannelPromise writePromise = context.channel().newPromise();
this.writeNotification(context, responseFuture, writePromise);
}
/**
*
* @param context
* @param responsePromise
* @param writePromise
*/
private void writeNotification(final ChannelHandlerContext context, final NotificationFuture, ?> responsePromise, final ChannelPromise writePromise) {
if (context.channel().isActive()) {
final int streamId = this.connection().local().incrementAndGetNextStreamId();
if (streamId > 0) {
// We'll attach the push notification and response promise to the stream as soon as the stream is created.
// Because we're using a StreamBufferingEncoder under the hood, there's no guarantee as to when the stream
// will actually be created, and so we attach these in the onStreamAdded listener to make sure everything
// is happening in a predictable order.
this.promiseStreamId.put(streamId, responsePromise);
final Notification notification = responsePromise.getNotification();
final Http2Headers headers = buildHeader(notification, context, streamId);
final ChannelPromise headersPromise = context.newPromise();
this.encoder().writeHeaders(context, streamId, headers, 0, false, headersPromise);
logger.trace("Wrote headers on stream {}: {}", streamId, headers);
String payload = notification.payloadJson();
final ByteBuf payloadBuffer =
Unpooled.wrappedBuffer(payload.getBytes(StandardCharsets.UTF_8));
final ChannelPromise dataPromise = context.newPromise();
this.encoder().writeData(context, streamId, payloadBuffer, 0, true, dataPromise);
logger.trace("Wrote payload on stream {}: {}", streamId, notification.payloadJson());
final PromiseCombiner promiseCombiner = new PromiseCombiner(context.executor());
promiseCombiner.addAll((ChannelFuture) headersPromise, dataPromise);
promiseCombiner.finish(writePromise);
} else {
// This is very unlikely, but in the event that we run out of stream IDs, we need to open a new
// connection. Just closing the context should be enough; automatic reconnection should take things
// from there.
writePromise.tryFailure(STREAMS_EXHAUSTED_EXCEPTION);
context.channel().close();
}
} else {
writePromise.tryFailure(STREAM_CLOSED_BEFORE_REPLY_EXCEPTION);
}
}
protected Http2Headers buildHeader(Notification notification,ChannelHandlerContext context,int streamId) {
final Http2Headers headers = new DefaultHttp2Headers()
.method(HttpMethod.POST.asciiName())
.authority(this.authority)
.path(Constant.header_path_format.concat(notification.getDeviceToken()))
.scheme(HttpScheme.HTTPS.name())
.addInt(Constant.header_apns_expiration, (int)notification.getExpiration());
if (notification.getApnsCollapseId() != null) {
headers.add(Constant.header_apns_collapse_id, notification.getApnsCollapseId());
}
if (notification.getPriority() != null) {
headers.addInt(Constant.header_apns_priority, notification.getPriority().getCode());
}
if (notification.getType() != null) {
headers.add(Constant.header_apns_push_type , notification.getType().name());
}
if (!StringUtils.isEmpty(notification.getTopic())) {
headers.add(Constant.header_apns_topic , notification.getTopic());
}
else{
headers.add(Constant.header_apns_topic , this.defaultTopic);
}
headers.add(Constant.header_apns_id, notification.getApnsId());
return headers;
}
private void handleEndOfStream(final ChannelHandlerContext context, final Http2Stream stream, final Http2Headers headers, final ByteBuf data) {
final NotificationFuture, NotificationResponse>> responseFuture =
stream.getProperty(this.responsePromisePropertyKey);
final Notification notification = responseFuture.getNotification();
final HttpResponseStatus status = HttpResponseStatus.parseLine(headers.status());
String apnsId = headers.get(Constant.header_apns_id).toString();
ResponseCode code = ResponseCode.of(status.code());
if (HttpResponseStatus.OK.equals(status)) {
responseFuture.complete(new NotificationResponse(code, apnsId , notification, null));
} else {
if (data != null) {
NotificationError errorResponse = JSON.parseObject(data.toString(StandardCharsets.UTF_8), NotificationError.class);
responseFuture.complete(new NotificationResponse(code, apnsId , notification, errorResponse));
} else {
logger.warn("Gateway sent an end-of-stream HEADERS frame for an unsuccessful notification.");
}
}
}
private Promise getChannelReadyPromise(final Channel channel) {
return channel.attr(Constant.channel_ready_promise_attribute_key).get();
}
// -------------------------- connection listener ---------------------------------
@Override
public void onStreamAdded(Http2Stream stream) {
stream.setProperty(responsePromisePropertyKey, promiseStreamId.remove(stream.id()));
}
@Override
public void onStreamActive(Http2Stream stream) {
}
@Override
public void onStreamHalfClosed(Http2Stream stream) {
}
@Override
public void onStreamClosed(Http2Stream stream) {
// Always try to fail promises associated with closed streams; most of the time, this should fail silently, but
// in cases of unexpected closure, it will make sure that nothing gets left hanging.
final CompletableFuture> responsePromise =
stream.getProperty(this.responsePromisePropertyKey);
if (responsePromise != null) {
final Throwable cause;
if (stream.getProperty(this.streamErrorCausePropertyKey) != null) {
cause = stream.getProperty(this.streamErrorCausePropertyKey);
}else if (this.connectionErrorCause != null) {
cause = this.connectionErrorCause;
} else {
cause = STREAM_CLOSED_BEFORE_REPLY_EXCEPTION;
}
responsePromise.completeExceptionally(cause);
}
}
@Override
public void onStreamRemoved(Http2Stream stream) {
stream.removeProperty(this.responseHeadersPropertyKey);
stream.removeProperty(this.responsePromisePropertyKey);
}
@Override
public void onGoAwaySent(int lastStreamId, long errorCode, ByteBuf debugData) {
}
@Override
public void onGoAwayReceived(int lastStreamId, long errorCode, ByteBuf debugData) {
}
@Override
protected void onStreamError(ChannelHandlerContext ctx, boolean outbound, Throwable cause, Http2Exception.StreamException http2Ex) {
final Http2Stream stream = this.connection().stream(http2Ex.streamId());
// The affected stream may already be closed (or was never open in the first place)
if (stream != null) {
stream.setProperty(this.streamErrorCausePropertyKey, http2Ex);
}
super.onStreamError(ctx, outbound, cause, http2Ex);
}
@Override
protected void onConnectionError(ChannelHandlerContext ctx, boolean outbound, Throwable cause, Http2Exception http2Ex) {
this.connectionErrorCause = http2Ex != null ? http2Ex : cause;
super.onConnectionError(ctx, outbound, cause, http2Ex);
}
// -------------------------- connection listener ---------------------------------
// -------------------------- frame listener ---------------------------------
@Override
public int onDataRead(ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding, boolean endOfStream) throws Http2Exception {
final int bytesProcessed = data.readableBytes() + padding;
if (endOfStream) {
final Http2Stream stream = this.connection().stream(streamId);
this.handleEndOfStream(ctx, stream, stream.getProperty(this.responseHeadersPropertyKey), data);
} else {
logger.error("Gateway sent a DATA frame that was not the end of a stream.");
}
return bytesProcessed;
}
@Override
public void onHeadersRead(ChannelHandlerContext ctx, int streamId, Http2Headers headers, int padding, boolean endOfStream) throws Http2Exception {
final Http2Stream stream = this.connection().stream(streamId);
if (endOfStream) {
this.handleEndOfStream(ctx, stream, headers, null);
} else {
stream.setProperty(this.responseHeadersPropertyKey, headers);
}
}
@Override
public void onHeadersRead(ChannelHandlerContext ctx, int streamId, Http2Headers headers, int streamDependency, short weight, boolean exclusive, int padding, boolean endOfStream) throws Http2Exception {
this.onHeadersRead(ctx, streamId, headers, padding, endOfStream);
}
@Override
public void onPriorityRead(ChannelHandlerContext ctx, int streamId, int streamDependency, short weight, boolean exclusive) throws Http2Exception {
}
@Override
public void onRstStreamRead(ChannelHandlerContext ctx, int streamId, long errorCode) throws Http2Exception {
if (errorCode == Http2Error.REFUSED_STREAM.code()) {
// This can happen if the server reduces MAX_CONCURRENT_STREAMS while we already have notifications in
// flight. We may get multiple RST_STREAM frames per stream since we send multiple frames (HEADERS and
// DATA) for each push notification, but we should only get one REFUSED_STREAM error; the rest should all be
// STREAM_CLOSED.
retryNotificationFromStream(ctx, streamId);
}
}
@Override
public void onSettingsAckRead(ChannelHandlerContext ctx) throws Http2Exception {
}
@Override
public void onSettingsRead(ChannelHandlerContext ctx, Http2Settings settings) throws Http2Exception {
logger.debug("Received settings from APNs gateway: {}", settings);
// Always try to mark the "channel ready" promise as a success after we receive a SETTINGS frame. If it's the
// first SETTINGS frame, we know all handshaking and connection setup is done and the channel is ready to use.
// If it's a subsequent SETTINGS frame, this will have no effect.
getChannelReadyPromise(ctx.channel()).trySuccess(ctx.channel());
}
@Override
public void onPingRead(ChannelHandlerContext ctx, long data) throws Http2Exception {
}
@Override
public void onPingAckRead(ChannelHandlerContext ctx, long data) throws Http2Exception {
if (this.idleFuture != null) {
this.idleFuture.cancel(false);
} else {
logger.error("Received PING ACK, but no corresponding outbound PING found.");
}
}
@Override
public void onPushPromiseRead(ChannelHandlerContext ctx, int streamId, int promisedStreamId, Http2Headers headers, int padding) throws Http2Exception {
}
@Override
public void onGoAwayRead(ChannelHandlerContext ctx, int lastStreamId, long errorCode, ByteBuf debugData) throws Http2Exception {
logger.info("Received GOAWAY from APNs server: {}", debugData.toString(StandardCharsets.UTF_8));
}
@Override
public void onWindowUpdateRead(ChannelHandlerContext ctx, int streamId, int windowSizeIncrement) throws Http2Exception {
}
@Override
public void onUnknownFrame(ChannelHandlerContext ctx, byte frameType, int streamId, Http2Flags flags, ByteBuf payload) throws Http2Exception {
}
// -------------------------- frame listener ---------------------------------
public static class ApnsClientChannelHandlerBuilder extends AbstractHttp2ConnectionHandlerBuilder
{
protected String authority;
protected Duration idleInterval;
protected String defaultTopic;
public ApnsClientChannelHandlerBuilder authority(final String authority) {
this.authority = authority;
return this;
}
public ApnsClientChannelHandlerBuilder defaultTopic(String topic) {
this.defaultTopic = topic;
return this;
}
public ApnsClientChannelHandlerBuilder idleInterval(final Duration idleIntervalMillis) {
this.idleInterval = idleIntervalMillis;
return this;
}
@Override
public ApnsClientChannelHandlerBuilder frameLogger(final Http2FrameLogger frameLogger) {
return super.frameLogger(frameLogger);
}
@Override
public Http2FrameLogger frameLogger() {
return super.frameLogger();
}
@Override
protected final boolean isServer() {
return false;
}
@Override
protected boolean encoderEnforceMaxConcurrentStreams() {
return true;
}
@Override
public ApnsClientChannelHandler build(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings) {
Objects.requireNonNull(this.authority, "Authority must be set before building an ApnsClientHandler.");
final ApnsClientChannelHandler handler = new ApnsClientChannelHandler(decoder, encoder, initialSettings, this.authority,this.defaultTopic, this.idleInterval);
this.frameListener(handler);
return handler;
}
@Override
public ApnsClientChannelHandler build() {
return super.build();
}
}
}