Как организовать отправку push-уведомлений на айфон

в 15:05, , рубрики: apns, apple, perl, Блог компании Surfingbird, разработка под iOS

Мы в Surfingbird используем пуш-уведомления, чтобы сообщать нашим пользователям срочные новости и просто информировать их об интересных материалах за день. Уже в первые недели тестов пуши показали свою огромную эффективность в плане увлечения ретеншена. Этому есть логичное объяснение – телефон у пользователя всегда с собой, в метро, в туалете, на совещаниях и т. д. Когда юзеру приходит пуш, все его внимание концентрируется на этом уведомлении. Однако, когда мы только начали внедрять пуши, то столкнулись с некоторыми трудностями. О трудностях и их преодолении мы и хотим рассказать в этом посте.

image


Начнем с того, что мы изначально не хотели использовать сторонние сервисы, такие как Amazon SNS, Parse, Push IO и т. д. Как бы удобны ни были все эти решения, они лишают вас гибкости и возможности как-то повлиять на процесс.

Немного о том, зачем нам вообще нужны пуш-уведомления. Если в мире происходит какое-то важное событие, мы хотим максимально быстро известить об этом наших пользователей. Не важно, что это за событие: с полок супермаркетов исчез пармезан, курс доллара бьет все рекорды, авария в метро или японцы начали выкладывать в инстаграм котиков в кукольных кроватках из Икеи. Любой подобный инфоповод нужно максимально быстро отработать и доставить в телефон пользователя.

Мы, конечно же, сразу пошли на metacpan в поисках готового модуля.
Первым нам на глаза попался Net::APNS. Нам очень понравился код милейший аватар с лисичкой. Но код был совершенно непригоден для использования в продакшене. Он открывал соединение перед каждой отправкой сообщения на каждый девайс и, после отправки, закрывал его. Это, во-первых, занимает очень много времени, а во-вторых, может (и будет) воспринято Apple как DoS-атака.

Ну что же, в целом код модуля понятный и поддерживаемый, осталось лишь немного его обогатить. В самом начале мы использовали простой формат уведомлений:

image

Код получился такой:

package Birdy::PushNotification::APNS;

use 5.018;
use Mojo::Base -base;
no if $] >= 5.018, warnings => "experimental";

use Socket;
use Net::SSLeay qw/die_now die_if_ssl_error/;

use JSON::XS;
use Encode qw(encode);

BEGIN {

    $Net::SSLeay::trace = 4;
    $Net::SSLeay::ssl_version = 10;

    Net::SSLeay::load_error_strings();
    Net::SSLeay::SSLeay_add_ssl_algorithms();
    Net::SSLeay::randomize();
}

sub new {
    my ($class, $sandbox) = @_;

    my $port = 2195;
    my $address = $sandbox 
                ? 'gateway.sandbox.push.apple.com'
                : 'gateway.push.apple.com';

    my $apns_key  = "$ENV{MOJO_HOME}/apns_key.pem";
    my $apns_cert = "$ENV{MOJO_HOME}/apns_cert.pem";

    my $socket;
    socket(
        $socket, PF_INET, SOCK_STREAM, getprotobyname('tcp')
    ) or die "socket: $!";

    connect(
        $socket, sockaddr_in( $port, inet_aton($address) )
    ) or die "Connect: $!";

    my $ctx = Net::SSLeay::CTX_new() or die_now("Failed to create SSL_CTX $!.");

    Net::SSLeay::CTX_set_options($ctx, &Net::SSLeay::OP_ALL);
    die_if_ssl_error("ssl ctx set options");

    Net::SSLeay::CTX_use_RSAPrivateKey_file($ctx, $apns_key, &Net::SSLeay::FILETYPE_PEM);
    die_if_ssl_error("private key");

    Net::SSLeay::CTX_use_certificate_file($ctx, $apns_cert, &Net::SSLeay::FILETYPE_PEM);
    die_if_ssl_error("certificate");

    my $ssl = Net::SSLeay::new($ctx);

    Net::SSLeay::set_fd($ssl, fileno($socket));
    Net::SSLeay::connect($ssl) or die_now("Failed SSL connect ($!)");

    my $self = bless {
        'ssl'    => $ssl, 
        'ctx'    => $ctx,
        'socket' => $socket,
    }, $class;
 
    return $self;
}

sub close {
    my ($self) = @_;

    my ($ssl, $ctx, $socket) = @{$self}{qw/ssl ctx socket/}

    CORE::shutdown($socket, 1);
    Net::SSLeay::free($ssl);
    Net::SSLeay::CTX_free($ctx);
    close($socket);
}

sub write {
    my ($self, $token, $alert, $data_id, $sound) = @_;

    Net::SSLeay::write(
        $self->{'ssl'}, 
        $self->_pack_payload($token, $alert, $data_id, $sound)
    );
}

sub _pack_payload {
    my ($self, $token, $alert, $data_id, $sound) = @_;

    my $data = {
        'aps' => {
            'alert' => encode('unicode', $alert),
        },
        'data_id' => $data_id,
    };

    # добавляем звук
    $data->{'aps'}->{'sound'} = 'default' if $sound;

    my $xs = JSON::XS->new->utf8(1);
    my $payload =
          chr(0)
        . pack('n',  32)
        . pack('H*', $token);

    # кеширование внутри переменной
    if (!$self->{'_alert'}) {

        # необходимо такое сообщение, чтобы полезная нагрузка не превышала 256 байт
        my $json = $xs->encode($data);
        my $overload = length($payload) + length(pack 'n', length $json) + length($json) - 256;

        if ($overload > 0) {
            substr( $data->{'aps'}->{'alert'}, -$overload ) = '';
        }

        # сохраним чтобы больше не пересчитывать
        $self->{'_alert'} = $data->{'aps'}->{'alert'};
    }

    my $json = $xs->encode($data);
    $payload .= pack('n',  length $json) . $json;

    return $payload;
}

Однако, после первых же тестов стало понятно, что в продакшене лучше не пользоваться простым форматом нотификации. Всё дело в том, что если вы отправляете неверное или неразборчивое уведомление, Apple возвращает ошибку и закрывает соединение.
В дальнейшем, все уведомления, отправленные по тому же соединению, будут отброшены и их нужно послать повторно. Ошибка возвращается в таком формате:

image

Как видно, в описании ошибки указан идентификатор уведомления, вызвавшего эту ошибку. Для того, чтобы у уведомлений были идентификаторы, необходимо использовать расширенный формат уведомлений:

image

Для этого перепишем два метода. В качестве $push_id можно использовать порядковый номер токена:

sub write {
    my ($self, $token, $alert, $data_id, $sound, $push_id) = @_;

    Net::SSLeay::write(
        $self->{'ssl'}, 
        $self->_pack_payload($token, $alert, $data_id, $sound, $push_id)
    );
}

sub _pack_payload {
    my ($self, $token, $alert, $data_id, $sound, $push_id) = @_;

    my $data = {
        'aps' => {
            'alert' => encode('unicode', $alert),
        },
        'data_id' => $data_id,
    };

    # добавляем звук
    $data->{'aps'}->{'sound'} = 'default' if $sound;

    my $xs = JSON::XS->new->utf8(1);
    my $payload =
          chr(1)
        . pack('N',  $push_id)
        . pack('N',  time + (3600 * 24) )
        . pack('n',  32)
        . pack('H*', $token);

    # кеширование внутри переменной
    if (!$self->{'_alert'}) {

        # необходимо такое сообщение, чтобы полезная нагрузка не превышала 256 байт
        my $json = $xs->encode($data);
        my $overload = length($payload) + length(pack 'n', length $json) + length($json) - 256;

        if ($overload > 0) {
            substr( $data->{'aps'}->{'alert'}, -$overload ) = '';
        }

        # сохраним чтобы больше не пересчитывать
        $self->{'_alert'} = $data->{'aps'}->{'alert'};
    }

    my $json = $xs->encode($data);
    $payload .= pack('n',  length $json) . $json;

    return $payload;
}

Так гораздо лучше! Теперь можно спокойно отправлять пуши, до тех пор, пока не придёт ответ с ошибкой. После чего, из описания ошибки вытаскиваем идентификатор проблемного уведомления, закрываем старое и открываем новое соединение, продолжаем отправлять уведомления с того места, где произошла ошибка.
С проблемным уведомлением нужно разбираться в частном порядке, причин может быть несколько и все они описаны здесь.

Но и это ещё не всё. Пользователь может удалить ваше приложение или просто запретить приём пушей. Для того, чтобы не слать уведомления по мёртвым токенам, нужно использовать фидбек сервис. Он возвращает список всех токенов, на которые больше не стоит слать уведомления. Формат фидбека:

image

Как рекомендует Apple, достаточно всего раз в сутки, по крону, подключаться к feedback.push.apple.com:2196 и читать из сокета. Полученные токены нужно просто удалить из базы данных.

sub read_feedback {
    my ($self) = @_;

    my $result = [];
    my $bytes = Net::SSLeay::read( $self->{'ssl'} );

    while ($bytes) {
        my ($ts, $token);

        ($ts, $token, $bytes) = unpack 'N n/a a*', $bytes;
        $token = unpack 'H*', $token;

        push @$result, {
            'ts' => $ts,
            'token' => $token,
        };
    }

    return $result;
}

К слову, для отправки уведомлений на андроид, достаточно сделать обычный http-запрос, в котором можно передать сразу 1000 токенов. Конечно же, параллельно можно делать несколько запросов. А если и это кажется слишком медленным, можно воспользоваться Cloud Connection Server (XMPP).

В комментариях нам было бы интересно узнать, как вы решаете задачу push-нотификации в своих приложениях.

1;

Автор: mshershnev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js