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

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

    Мы реализовали отправку пуш-уведомлений с бекенда на языке программирования Perl. Однако, когда мы только начали внедрять пуши, то столкнулись с некоторыми трудностями. О трудностях и их преодолении мы и хотим рассказать в этом посте.

    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;
    Surfingbird
    0.00
    Company
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 15

      +2
      А у нас Go трудится над пушами. И шлем фреймы во втором формате (у вас первый).

      >> и все они описаны здесь.
      Не все описаны. Еще есть как минимум 11 и 128. Apple вообще, похоже, забили на поддержку документации.
        0
        а что за «второй» формат фрейма?
          +2
          Вот тут описан второй формат
            0
            Да, он самый. Раз уж рекомендуется все новое делать на нем, то с ним с самого начала и развлекались)
        0
        Тоже допиливал как-то Net::APNS
          +9
          Удалил ваше приложение, как раз из-за ваших пуш уведомлений.
            +3
            Я не представляю, что делает приложение топикстартера и имеет ли оно практически смысл, но ведь в настройках системы всегда можно отключить нотификации. Мне много кто хочет слать сообщения (радио-плейер, кинотеатр?!, игры..), но мало кто получает разрешение это делать.
              0
              маленький хак для андроида: держите на пуше палец 2 секунды, появляется меню «Приложение» переходите в настройки приложений и отключаете пуш от него (вернее не пуш, а возможность рисовать нотификации) на уровне системы.
              +7
              Как бы удобны ни были все эти решения, они лишают вас гибкости и возможности как-то повлиять на процесс.

              Раскройте мысль, пожалуйста.
                0
                Когда между нами и клиентом есть дополнительная прослойка в виде чёрного ящика, который вы не в состоянии контролировать, это плохо. Конечно, если доставка пуш уведомлений не критичена, например, в приложениях, где нет breaking news или в играх, где можно днями размазывать на всю базу пользователей, анонс какого-то события то да, лучше не изобретать велосипед.

                В нашем случае очень важна скорость доставки. Сторонние же сервисы вносят дополнительны неопределённые задержки, на которые мы никак не можем повлиять.
                  +1
                  Какое счастье, что есть игрок, который контролирует отправку уведомлений ;)
                0
                На сегодняшний день, в городе где я живу, в квартирах у людей не осталось ни одной радиоточки, так-что пожалуй, подобные пуши остались одними из немногих способов быстро проинформировать население в случае возникновения черезвычайной ситуации.
                  0
                  А можете куда-либо залить полный код модуля?
                  В тексте никаких следов от Net::APNS не видно.
                  Кода для переоткрытие сокета в случае ошибки при записи — тоже не видно, из чего создается впечатление, что вы привели не все функции модуля.
                    0
                    .
                      0
                      Конечно, это не весь код. К сожалению, или к счастью, есть такая штука как NDA и я просто не могу скопировать наш рабочий код.
                      Мне, конечно же, близки идеи опен соурс и я надеюсь в скором будущем опубликовать полностью рабочий модуль на cpan.

                    Only users with full accounts can post comments. Log in, please.