Android

8 mai, 2017

Estabilidade de engenharia em migrações: movendo para immutable collections em aplicativos Android da Uber

Publicidade

Fazer modelos para aplicativos Android frequentemente envolve a escrita de um monte de código padronizado à mão. Esse processo pode ser demorado, propenso a erros e levar a erros difíceis de detectar quando feito incorretamente. Para resolver isso na Uber, criamos uma pilha personalizada para gerar modelos AutoValue e clientes de rede a partir de Thrift Specs.

Neste artigo, nós exploramos como os modelos nos apps Android da Uber acabaram sendo mutáveis apesar do uso de AutoValue; ilustramos algumas das armadilhas da mutabilidade e detalhamos uma classe de erros causados pelo uso de classes de coleta mutáveis em nossos modelos AutoValue; e, finalmente, explicamos como realizamos com segurança uma grande migração que não tinha segurança em tempo de compilação para mover para classes de immutable collections sem afetar a estabilidade de nossos aplicativos.

Pilha de geração de modelos Android da Uber

Mesmo que o mecanismo de transporte para nossos aplicativos seja JavaScript Object Notation (JSON), usamos Thrift em nossa pilha de geração de modelos para definir especificações compartilhadas entre o cliente e o backend. Isso torna as chamadas de serviço mais consistentes e impede que as definições de serviço fiquem fora de sincronia. Para gerar modelos para os nossos aplicativos Android, Thrifty é usado para analisar as especificações, e JavaPoet é usado para criar classes AutoValue.

A pilha de geração de modelos Android da Uber usa AutoValue para evitar mutabilidade.

O AutoValue nos permite produzir facilmente classes de valor imutáveis, criando classes abstratas e anotando-as com @AutoValue. AutoValue, então, executa seu processador de anotação sobre as classes abstratas para gerar implementações de apoio delas. No entanto, se você não for cuidadoso, esses modelos podem inadvertidamente se tornar mutáveis e causar erros. Encontramos uma mutabilidade inadvertida ao incluir classes de coleta em nossos modelos AutoValue.

Mutabilidade em modelos AutoValue

O modelo Rider abaixo é um modelo AutoValue gerado a partir de uma especificação Thrift:

@AutoValue
public abstract class Rider {
    public abstract String firstName();
    public abstract String lastName();
    public abstract String phoneNumber();
    public abstract List<PaymentProfile> paymentProfiles();
    public abstract Map<String, String> thirdPartyIdentities();
    
    @AutoValue.Builder
    public abstract static class Builder {
        public abstract Builder firstName(String firstName);
        public abstract Builder lastName(String lastName);
        public abstract Builder phoneNumber(String mobileDidgits);
        public abstract Builder paymentProfiles(List<PaymentProfile> profiles);
        public abstract Builder thirdPartyIdentities(Map<String, String> identities);
        public abstract Rider build();
    }
}

Rider Model Gist: Um modelo que representa um rider (alguém que solicita um Uber).

Uma vez que o modelo define propriedades que usam as collections interfaces List e Map, não é verdadeiramente imutável, a menos que o modelo seja criado com implementações ImmutableList e ImmutableMap.

Enviamos o modelo Rider através da Rede como JavaScript Object Notation (JSON) e desserializamo-lo com Gson.  Fora da caixa, o Gson retorna implementações mutáveis de collections interfaces quando desserializa respostas de rede. Isso permitiu-nos escrever código que alterou coleções em modelos que considerávamos “imutáveis”. Ser capaz de alterar coleções em modelos resultou em erros que eram difíceis de detectar e difíceis de reproduzir.

Um tipo de erro que apareceu com frequência em todos os nossos aplicativos se materializou quando apoiamos os Reactive Data Streams com coleções de modelos. O problema surgiu quando essas collections seriam modificadas a fim de popular Views. A classe PricedVehicles mostra um exemplo disso:

/**
 * Provides a stream of vehicles that have a price.
 */
public class PricedVehicles {

    private Observable<List<Vehicle>> pricedVehicles;

    public PricedVehicles(VehicleStream vehicleStream, PricingStream pricingStream) {
        this.pricedVehicles = Observable.combineLatest(
                vehicleStream.vehicles(),
                pricingStream.prices(),
                PricedVehicles::filterVehiclesWithoutPrice);
    }

    /**
     * @return a stream that emits lists of priced vehicles.
     */
    public Observable<List<Vehicle>> stream() {
        return pricedVehicles;
    }

    /**
     * Removes vehicles that don't have a price in the prices map.
     * @param vehicles the list of vehicles that are available.
     * @param prices a map containing the prices of vehicles.
     */
    private static List<Vehicle> filterVehiclesWithoutPrice(
            List<Vehicle> vehicles,
            Map<VehicleId, Price> prices) {
        List<Vehicle> vehiclesToRemove = new ArrayList<>();
        for(Vehicle vehicle : vehicles) {
            if (prices.get(vehicle.id()) == null) {
                vehiclesToRemove.add(vehicle);
            }
        }
        vehicles.removeAll(vehiclesToRemove);
        return vehicles;
    }
}

Reactive Stream Mutation Gist: Uma classe que fornece um fluxo de veículos com um preço através de uma API de Fluxos Reativos.

A classe PricedVehicles filtra os veículos que não têm preços que estão presentes no VehicleStream, e os veículos restantes (aqueles que têm preços) são expostos através da API PricedVehicles.stream(). O problema aqui é que PricedVehicles.filterVehiclesWithoutPrice() muda a lista de veículos que apoia VehicleStream. Então, VehicleStream não retornará todos os veículos depois de filterVehiclesWithoutPrice() ter sido chamado.

Isso resultou em dados inconsistentes mostrados aos usuários, já que os fluxos são compartilhados entre views. Esses tipos de erros podem ser difíceis de localizar, pois requerem que um usuário acesse views em uma determinada ordem. Nesse caso, os dados inconsistentes são mostrados somente se PricedVehicles.filterVehiclesWithoutPrice() for chamado antes da view que quer mostrar todos os veículos em VehicleStream.

Se nossos modelos usaram classes immutable collections, esses tipos de erros não podem ocorrer; objetos imutáveis são seguros para compartilhar entre classes porque eles não podem ser modificados. Para esclarecimentos adicionais sobre por que eles são seguros para compartilhar, sugerimos a leitura de Effective Java Second Edition: Item 15 Minimize Mutability, por Joshua Block.

Movendo para immutable collections

Queremos que nossos modelos retornem classes immutable colletions, não as mutáveis. Na Uber, usamos um pequeno fork de guava que nos fornece immutable colletions. O modelo Rider abaixo especifica que ImmutableMap e ImmutableList devem ser retornados quando paymentProfiles() e thirdPartyIdentities() forem chamados:

/**
 * Provides a stream of vehicles that have a price.
 */
public class PricedVehicles {

    private Observable<List<Vehicle>> pricedVehicles;

    public PricedVehicles(VehicleStream vehicleStream, PricingStream pricingStream) {
        this.pricedVehicles = Observable.combineLatest(
                vehicleStream.vehicles(),
                pricingStream.prices(),
                PricedVehicles::filterVehiclesWithoutPrice);
    }

    /**
     * @return a stream that emits lists of priced vehicles.
     */
    public Observable<List<Vehicle>> stream() {
        return pricedVehicles;
    }

    /**
     * Removes vehicles that don't have a price in the prices map.
     * @param vehicles the list of vehicles that are available.
     * @param prices a map containing the prices of vehicles.
     */
    private static List<Vehicle> filterVehiclesWithoutPrice(
            List<Vehicle> vehicles,
            Map<VehicleId, Price> prices) {
        List<Vehicle> vehiclesToRemove = new ArrayList<>();
        for(Vehicle vehicle : vehicles) {
            if (prices.get(vehicle.id()) == null) {
                vehiclesToRemove.add(vehicle);
            }
        }
        vehicles.removeAll(vehiclesToRemove);
        return vehicles;
    }
}

Rider with Immutable Collections Gist: Nosso modelo Rider que usa coleções imutáveis.

No entanto, passar de collection interfaces para classes immutable concrete não foi tão simples como fazer com que todos os nossos modelos retornassem ImmutableList e ImmutableMap. Essas classes não oferecem qualquer segurança de tempo de compilação contra mutações e falha em tempo de execução quando modificado. Isso ocorre porque coleções de guava lançam UnsupportedOperationException quando um método de coleta que altera o estado é chamado.

Lembre-se, as coleções imutáveis de guava implementam interfaces de coleção java.util que definem métodos como add() e remove(). A fim de garantir que nenhuma falha ocorreria depois de passar para classes concretas imutáveis, teríamos de vetar o aplicativo inteiro. Isso não é prático, pois nossos aplicativos têm centenas de colaboradores.

Migrando com segurança: mutable collections trackeadas

Para migrar para classes de coleção imutáveis sem quebrar os usuários de produção, fizemos uma alteração API-invisível na camada de serialização de rede (via TypeAdapters do Gson) que retorna uma implementação especial de cada colletion interface java.util. Isso nos permitiu continuar usando collection interfaces em nossas APIs de modelo, mas apoiá-las sob o capô com coleções mutáveis controladas (tracked mutable collections – TMCs).

Um TMC é um tipo especial de implementação de collection que nos permitiu quebrar quando uma collection foi alterada na variante de compilação de depuração de nossos aplicativos e fazer logon no release. Para quebrar na variante de depuração de nossos aplicativos, usamos um Timber.Tree que relança invocações Timber.e(). Nosso gist de Tracked Mutable Collections mostra cada um de nossos TMCs e os TypeAdapterFactories usados para criá-los. Você notará que cada TMC chama Timber.e() quando métodos de mutação são chamados.

Ao registrar mutações na produção, conseguimos detectar todos os locais onde as coleções foram alteradas sem manualmente examinarmos o aplicativo inteiro. Após dois meses, paramos de ver as mutações sendo registradas na produção e movemos todos os nossos modelos para usar immutable collection em vez de colletion interfaces. Uma vez que os TMCs nos proporcionaram um alto grau de segurança, essa grande migração de tentativa única nos permitiu mudar para classes immutable concrete sem afetar nenhum usuário na produção.

Trabalhar com uma pilha personalizada para gerar modelos e clientes de rede nos permitiu fazer mudanças de API de modelo rapidamente em todas os nossos aplicativos. Executar uma grande migração como esta frequentemente envolve segurança de engenharia no processo, muitas vezes de maneiras únicas. Desde a mudança para immutable colections, investimos na captura de mutações de collection em modelos em tempo de compilação com análise estática.

***

Este artigo é do Uber Engineering. Ele foi escrito por Warren Smith e Molly Vorwerck. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/immutable-collections/.