WEB3DEV

Cover image for Tutorial libp2p: Crie um aplicativo ponto a ponto em Rust
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

Tutorial libp2p: Crie um aplicativo ponto a ponto em Rust

14 de dezembro de 2020

Ao longo dos últimos anos, devido em grande parte da moda em torno das blockchains e criptomoedas, aplicativos descentralizados ganharam um pouco de impulso. Outro fator por trás do surgimento do interesse em descentralização é uma maior consciência sobre as desvantagens de colocar a maior parte de web na mão de um pequeno quadro de companhias em termos de privacidade de dados e monopolização.

De qualquer forma, houve alguns desenvolvimentos muito interessantes na cena de softwares descentralizados recentemente, mesmo além de toda tecnologia de criptomoedas e blockchain.

Exemplos notáveis incluem IPFS; a mais nova plataforma de codificação distribuída Radicle; a rede social descentralizada Scuttlebutt; e muitos outros aplicativos dentro do Fediverse, como a Mastodon.

Neste tutorial, mostraremos como construir um aplicativo ponto a ponto muito simples usando Rust e a fantástica biblioteca lipp2p, que existe em diferentes estágios de maturidade para uma ampla gama de linguagens.

Vamos construir um aplicativo de receitas culinárias com uma interface de linha de comando simples que nos habilita a:

  • Criar receitas.
  • Publicar receitas.
  • Listar receitas locais.
  • Listar outros pares que descobrimos na rede.
  • Listar receitas publicadas de um determinado par.
  • Listar todas as receitas de todos os pares que conhecemos.

Faremos tudo isso em cerca de 300 linhas da linguagem Rust. Vamos começar!

Instalando o Rust

Para acompanhar, tudo o que você precisa é uma instalação recente do Rust (1,47 ou mais).

Primeiro, crie um novo projeto Rust:

cargo new rust-p2p-example
cd rust-p2p-example
Enter fullscreen mode Exit fullscreen mode

Em seguida, edite o arquivo Cargo.toml e adicione as dependências que você precisará.

[dependencies]
libp2p = { version = "0.31", features = ["tcp-tokio", "mdns-tokio"] }
tokio = { version = "0.3", features = ["io-util", "io-std", "stream", "macros", "rt", "rt-multi-thread", "fs", "time", "sync"] }
serde = {version = "=1.0", features = ["derive"] }
serde_json = "1.0"
once_cell = "1.5"
log = "0.4"
pretty_env_logger = "0.4"
Enter fullscreen mode Exit fullscreen mode

Como mencionado acima, usaremos libp2p para a parte da rede ponto a ponto. Mais especificamente, vamos usá-la em conjunto com o tempo de execução assíncrono Tokio (Tokio async runtime). Usaremos Serde para serialização e desserialização de JSON e algumas bibliotecas auxiliares para o estado de registro e inicialização.

O que é a libp2p?

A libp2p é um conjunto de protocolos para a construção de aplicativos ponto a ponto com foco em modularidade.

Existem implementações de bibliotecas para várias linguagens, como JavaScript, Go e Rust. Todas essas bibliotecas implementam as mesmas especificações da libp2p, portanto, um cliente da libp2p que construiu com Go pode interagir confiante com outro cliente que escreveu em JavaScript, desde que eles sejam compatíveis em termos de pilha de protocolo escolhida. Esses protocolos abrangem uma ampla gama, desde protocolos de transporte de rede básicos a protocolos de camada de segurança e multiplexação.

Não vamos aprofundar nos detalhes da libp2p neste post, mas se você estiver interessado em mergulhar, os documentos oficiais da libp2p oferecem uma ótima visão geral dos vários conceitos que encontraremos ao longo do caminho.

Como a libp2p funciona

Para ver a libp2p em ação, vamos começar com o nosso aplicativo de receitas. Começaremos definindo algumas constantes e tipos que precisaremos:

const STORAGE_FILE_PATH: &str = "./recipes.json";

type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;

static KEYS: Lazy<identity::Keypair> = Lazy::new(|| identity::Keypair::generate_ed25519());
static PEER_ID: Lazy<PeerId> = Lazy::new(|| PeerId::from(KEYS.public()));
static TOPIC: Lazy<Topic> = Lazy::new(|| Topic::new("recipes"));
Enter fullscreen mode Exit fullscreen mode

Armazenaremos nossas receitas locais em um arquivo JSON simples chamado recipes.json, que o aplicativo esperará que esteja na mesma pasta que o executável. Também definimos um tipo auxiliar para Result, que nos permite propagar erros arbitrários.

Em seguida, usamos once_cell::Lazy, para inicializar lentamente algumas coisas. Primeiro e acima de tudo, nós o usamos para gerar um par de chaves e um chamado PeerId (identidade de par) derivado de uma chave pública. Também criamos um Topic (tópico), que é outro conceito chave da libp2p.

O que tudo isso significa? Em resumo, um PeerId é simplesmente um identificador único para um par específico em toda a rede ponto a ponto. Nós o derivamos de um par de chaves para garantir sua exclusividade. Além disso, o par de chaves nos permite comunicar seguramente com o resto da rede, certificando que ninguém possa se passar por nós.

Um Topic, por outro lado, é um conceito do Floodsub, que é uma implementação da interface pub/sub da libp2p. Um Topic é algo para o qual podemos subscribe (assinar) para enviar mensagens - por exemplo, para ouvir apenas um subconjunto do tráfego em uma rede pub/sub.

Também precisaremos de alguns tipos para a receita:

type Recipes = Vec<Recipe>;

#[derive(Debug, Serialize, Deserialize)]
struct Recipe {
    id: usize,
    name: String,
    ingredients: String,
    instructions: String,
    public: bool,
}
Enter fullscreen mode Exit fullscreen mode

E alguns tipos para as mensagens que planejamos enviar:

#[derive(Debug, Serialize, Deserialize)]
enum ListMode {
    ALL,
    One(String),
}

#[derive(Debug, Serialize, Deserialize)]
struct ListRequest {
    mode: ListMode,
}

#[derive(Debug, Serialize, Deserialize)]
struct ListResponse {
    mode: ListMode,
    data: Recipes,
    receiver: String,
}

enum EventType {
    Response(ListResponse),
    Input(String),
}
Enter fullscreen mode Exit fullscreen mode

A receita é bastante direta. Tem uma ID, um nome, alguns ingredientes e instruções para executá-la. Além disso, adicionamos uma bandeira public (pública) para podermos distinguir quais receitas queremos compartilhar e quais queremos manter para nós.

Como mencionado no início, existem duas maneiras de buscar listas de outros pares: de todos ou de um, que é representado pela enumeração ListMode.

Os tipos ListRequest e ListResponse são apenas empacotadores (wrappers) para este tipo e a data de envio usando-os.

A enumeração EventType distingue entre uma resposta de outro par e uma entrada de nós mesmos. Veremos mais adiante o porquê dessa diferença ser relevante.


Mais de 200 mil desenvolvedores usam LogRocket para criar melhores experiências digitais. Saiba mais.

Criando um cliente libp2p

Vamos começar escrevendo a função main (principal) para configurar um par em uma rede ponto a ponto.

#[tokio::main]
async fn main() {
    pretty_env_logger::init();

    info!("Peer Id: {}", PEER_ID.clone());
    let (response_sender, mut response_rcv) = mpsc::unbounded_channel();

    let auth_keys = Keypair::<X25519Spec>::new()
        .into_authentic(&KEYS)
        .expect("can create auth keys");
Enter fullscreen mode Exit fullscreen mode

Inicializamos o login e criamos um channel (canal) assíncrono para comunicar entre diferentes partes do aplicativo. Usaremos esse canal mais tarde para enviar respostas da pilha de rede libp2p de volta para nosso aplicativo lidar com elas.

Além disso, criamos algumas chaves de autenticação para o protocolo de criptomoeda Noise, que usaremos para proteger o tráfego dentro da rede. Para este objetivo, criamos um novo par de chaves e o registramos como nossas chaves de identidade, usando a função into_authentic.

O próximo passo é importante e envolve alguns conceitos principais do libp2p: a criação de um chamado Transporte .authenticate(NoiseConfig::xx(authkeys).intoauthenticated()) // XX padrão Handshake (Handshake pattern), IX também existe e IK – apenas XX atualmente fornece interoperabilidade com outros libp2p impls .multiplex(mplex::MplexConfig::new()) .boxed();).

   let transp = TokioTcpConfig::new()
        .upgrade(upgrade::Version::V1)
        .authenticate(NoiseConfig::xx(auth_keys).into_authenticated())
        .multiplex(mplex::MplexConfig::new())
        .boxed();
Enter fullscreen mode Exit fullscreen mode

Um transporte é um conjunto de protocolos de rede que habilita conexões - comunicação orientada entre pares. Também é possível usar vários transportes em um aplicativo - por exemplo, TCP/IP e Websockets ou UDP ao mesmo tempo para diferentes casos de uso.

Neste exemplo, usaremos o TCP como base usando o TCP assíncrono da Tokio. Uma vez que uma conexão TCP for estabelecida, vamos upgrade (atualizar) para usar Noise para comunicação segura. Um exemplo disso baseado na web seria usar TLS sobre HTTP para criar uma conexão segura.

Usamos o padrão Handshake NoiseConfig:xx , que é uma das três opções, porque é o único com garantia de interoperabilidade com outros aplicativos libp2p.

O bom do libp2p é que poderíamos escrever um cliente Rust e outro poderia escrever um cliente JavaScript e eles ainda poderiam facilmente se comunicar, desde que os protocolos estejam implementados em ambas versões da biblioteca.

No final, nós também multiplexamos o transporte, que nos permite multiplexar vários substreams, ou conexões, no mesmo transporte.

Ufa, isso é um pouco de teoria! Mas tudo isso pode ser encontrado nos documentos libp2p. Esta é apenas uma das muitas, muitas maneiras de criar um transporte ponto a ponto.

O próximo conceito é um NetworkBehaviour. Esta é a parte dentro da libp2p que realmente define a lógica da rede e todos os pares - por exemplo, o que fazer com os eventos recebidos e quais eventos enviar.

let mut behaviour = RecipeBehaviour {
        floodsub: Floodsub::new(PEER_ID.clone()),
        mdns: TokioMdns::new().expect("can create mdns"),
        response_sender,
    };

    behaviour.floodsub.subscribe(TOPIC.clone());
Enter fullscreen mode Exit fullscreen mode

Neste caso, como mencionado acima, usaremos o protocolo FloodSub para lidar com os eventos. Também usaremos mDNS, que é um protocolo para descobrir outros pares na rede local. Também colocaremos a parte sender do nosso canal aqui para podermos usá-lo para propagar eventos de volta para a parte principal do aplicativo.

O tópico FloodSub que criamos anteriormente está agora sendo inscrito no nosso comportamento, o que significa que receberemos eventos e podemos enviar eventos sobre esse tópico.

Mais artigos ótimos do LogRocket

Estamos quase finalizando a configuração da libp2p. O último conceito que precisamos é o Swarm.

let mut swarm = SwarmBuilder::new(transp, behaviour, PEER_ID.clone())
        .executor(Box::new(|fut| {
            tokio::spawn(fut);
        }))
        .build();
Enter fullscreen mode Exit fullscreen mode

Um Swarm gerencia as conexões criadas usando o transporte e executa o comportamento da rede que criamos, disparando e recebendo eventos e nos dando uma maneira de chegar até eles de fora.

Criamos o Swarm com o nosso transporte, comportamento e ID de par. A parte executor simplesmente informa ao Swarm para usar o tempo de execução Tokio para executar internamente, mas poderíamos também usar outros tempos de execução assíncronos aqui.

A única coisa que falta fazer é iniciar o nosso Swarm:

 Swarm::listen_on(
        &mut swarm,
        "/ip4/0.0.0.0/tcp/0"
            .parse()
            .expect("can get a local socket"),
    )
    .expect("swarm can be started");
Enter fullscreen mode Exit fullscreen mode

Semelhante a iniciar, por exemplo, um servidor TCP (Transmission Control Protocol ou Protocolo de controle de transmissão), simplesmente chamamos listen_on com um IP (Internet Protocol ou Protocolo de rede) local, deixando o OS (Operational System ou Sistema operacional) decidir a porta para nós. Isso iniciará o Swarm com toda a nossa configuração, mas ainda não definimos realmente nenhuma lógica.

Vamos começar lidando com a entrada do usuário.

Lidando com a entrada em libp2p

Para a entrada do usuário, simplesmente contaremos com o bom e velho STDIN (dispositivo de entrada padrão). Portanto, antes da chamada Swarm::listen_on, adicionaremos:

let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()).lines();
Enter fullscreen mode Exit fullscreen mode

Isso definiu um leitor assíncrono no STDIN, que lê a linha de fluxo por linha. Portanto, se pressionarmos enter, haverá uma nova mensagem recebida.

A próxima parte é criar nosso loop de evento, que ouvirá a eventos do STDIN, do Swarm e do nosso canal de resposta definido acima.

 loop {
        let evt = {
            tokio::select! {
                line = stdin.next_line() => Some(EventType::Input(line.expect("can get line").expect("can read line from stdin"))),
                event = swarm.next() => {
                    info!("Unhandled Swarm Event: {:?}", event);
                    None
                },
                response = response_rcv.recv() => Some(EventType::Response(response.expect("response exists"))),
            }
        };
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Usaremos o macro select da Tokio para aguardar vários processos assíncronos, lidando com o primeiro que finalizar. Não fazemos nada com os eventos Swarm; esses são manipulados dentro do nosso RecipeBehaviour, que veremos mais pra frente, mas ainda precisamos chamar swarm.next() para conduzir o avanço do Swarm.

Vamos adicionar alguma lógica de manipulação de eventos no lugar de :

       if let Some(event) = evt {
            match event {
                EventType::Response(resp) => {
                   ...
                }
                EventType::Input(line) => match line.as_str() {
                    "ls p" => handle_list_peers(&mut swarm).await,
                    cmd if cmd.starts_with("ls r") => handle_list_recipes(cmd, &mut swarm).await,
                    cmd if cmd.starts_with("create r") => handle_create_recipe(cmd).await,
                    cmd if cmd.starts_with("publish r") => handle_publish_recipe(cmd).await,
                    _ => error!("unknown command"),
                },
            }
        }
Enter fullscreen mode Exit fullscreen mode

Se houver um evento, o combinamos e vemos se é um evento Response ou um evento Input. Vamos olhar para os eventos Input apenas por enquanto.

Existem algumas opções. Apoiamos os seguintes comandos:

  • ls p lista todos os pares conhecidos.
  • ls r lista receitas locais.
  • ls r {peerId} lista as receitas publicadas de um certo par.
  • ls r all lista receitas publicadas de todos os pares conhecidos.
  • publish r {recipeId} publica uma determinada receita.
  • create r {recipeName}|{recipeIngredients}|{recipeInstructions cria uma nova receita com os dados fornecidos e uma ID de incrementação.

Listar todas as receitas dos pares, neste caso, significa enviar uma solicitação das receitas aos nossos pares, aguardar que eles respondam e exibir os resultados. Em uma rede ponto a ponto, isso pode levar um tempo, tendo em vista que alguns pares podem estar do outro lado do planeta e não sabemos se todos eles irão, pelo menos, nos responder. Isso é bem diferente de enviar uma solicitação para um servidor HTTP, por exemplo.

Vejamos a lógica para a listar os pares primeiro:

async fn handle_list_peers ( swarm : & mut Swarm < RecipeBehaviour >) { 
    info !( "Peers descobertos:" ); deixe nós = enxame . mdns . nós_descobertos (); let mut unique_peers = HashSet :: new (); para pares em nós { 
        unique_peers . inserir ( par ); } 
    unique_peers . 



    iter (). for_each (| p | info !( "{}" , p )); }
Enter fullscreen mode Exit fullscreen mode

Neste caso, podemos usar o mDNS para nos fornecer todos os nós descobertos, iterando e exibindo- os. Fácil.

Vamos percorrer a criação e a publicação de receitas a seguir, antes de abordar os comandos de lista:

async fn handle_create_recipe(cmd: &str) {
    if let Some(rest) = cmd.strip_prefix("create r") {
        let elements: Vec<&str> = rest.split("|").collect();
        if elements.len() < 3 {
            info!("too few arguments - Format: name|ingredients|instructions");
        } else {
            let name = elements.get(0).expect("name is there");
            let ingredients = elements.get(1).expect("ingredients is there");
            let instructions = elements.get(2).expect("instructions is there");
            if let Err(e) = create_new_recipe(name, ingredients, instructions).await {
                error!("error creating recipe: {}", e);
            };
        }
    }
}

async fn handle_publish_recipe(cmd: &str) {
    if let Some(rest) = cmd.strip_prefix("publish r") {
        match rest.trim().parse::<usize>() {
            Ok(id) => {
                if let Err(e) = publish_recipe(id).await {
                    info!("error publishing recipe with id {}, {}", id, e)
                } else {
                    info!("Published Recipe with id: {}", id);
                }
            }
            Err(e) => error!("invalid id: {}, {}", rest.trim(), e),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Em ambos os casos, precisamos analisar a string para obter os dados separados por | ou a ID da receita fornecida no caso de publish, registrando um erro se a entrada fornecida não for válida.

No caso de create, chamamos a função auxiliar create_new_recipe com os dados fornecidos. Vamos verificar todas as funções auxiliares que precisaremos para interagir com o nosso armazenamento JSON local simples para receitas:

async fn create_new_recipe(name: &str, ingredients: &str, instructions: &str) -> Result<()> {
    let mut local_recipes = read_local_recipes().await?;
    let new_id = match local_recipes.iter().max_by_key(|r| r.id) {
        Some(v) => v.id + 1,
        None => 0,
    };
    local_recipes.push(Recipe {
        id: new_id,
        name: name.to_owned(),
        ingredients: ingredients.to_owned(),
        instructions: instructions.to_owned(),
        public: false,
    });
    write_local_recipes(&local_recipes).await?;

    info!("Created recipe:");
    info!("Name: {}", name);
    info!("Ingredients: {}", ingredients);
    info!("Instructions:: {}", instructions);

    Ok(())
}

async fn publish_recipe(id: usize) -> Result<()> {
    let mut local_recipes = read_local_recipes().await?;
    local_recipes
        .iter_mut()
        .filter(|r| r.id == id)
        .for_each(|r| r.public = true);
    write_local_recipes(&local_recipes).await?;
    Ok(())
}

async fn read_local_recipes() -> Result<Recipes> {
    let content = fs::read(STORAGE_FILE_PATH).await?;
    let result = serde_json::from_slice(&content)?;
    Ok(result)
}

async fn write_local_recipes(recipes: &Recipes) -> Result<()> {
    let json = serde_json::to_string(&recipes)?;
    fs::write(STORAGE_FILE_PATH, &json).await?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Os blocos de construção mais básicos são read_local_recipes e write_local_recipes, que simplesmente lêem e desserializam ou serializam e escrevem receitas de ou para o arquivo de armazenamento.

O auxiliar write_local_recipes busca todas as receitas do arquivo, procura a receita com uma ID fornecida e configura sua bandeira public para verdadeiro.

Ao criar uma receita, também buscamos todas as receitas do arquivo, adicionamos uma nova receita no final e escrevemos novamente todos os dados, substituindo o arquivo. Isso não é super eficiente, mas é simples e funciona.

Enviando mensagens com a libp2p

Vamos ver os comandos list a seguir e explorar como podemos enviar mensagem para outros pares.

No comando list, existem três casos possíveis:

async fn handle_list_recipes(cmd: &str, swarm: &mut Swarm<RecipeBehaviour>) {
    let rest = cmd.strip_prefix("ls r ");
    match rest {
        Some("all") => {
            let req = ListRequest {
                mode: ListMode::ALL,
            };
            let json = serde_json::to_string(&req).expect("can jsonify request");
            swarm.floodsub.publish(TOPIC.clone(), json.as_bytes());
        }
        Some(recipes_peer_id) => {
            let req = ListRequest {
                mode: ListMode::One(recipes_peer_id.to_owned()),
            };
            let json = serde_json::to_string(&req).expect("can jsonify request");
            swarm.floodsub.publish(TOPIC.clone(), json.as_bytes());
        }
        None => {
            match read_local_recipes().await {
                Ok(v) => {
                    info!("Local Recipes ({})", v.len());
                    v.iter().for_each(|r| info!("{:?}", r));
                }
                Err(e) => error!("error fetching local recipes: {}", e),
            };
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

Analisamos o comando recebido, removendo a parte ls r e verificando o que resta. Se não houver mais nada no comando, podemos simplesmente buscar nossas receitas locais e imprimi-las usando os auxiliares definidos nas seções anteriores.

Se encontrarmos a palavra-chave all, criamos uma ListRequest com a configuração ListMode::ALL, serializamos para JSON e, usando a instância FloodSub dentro do nosso Swarm, a publicamos no Topic mencionado anteriormente.

A mesma coisa acontece se encontrarmos uma ID de par no comando, caso em que nós apenas enviaríamos o modo ListMode::One com essa ID do par. Poderíamos verificar se é uma ID de par válida ou se é até uma ID do par que descobrimos, mas vamos simplificar: se não houver ninguém para ouvi-lo, nada acontece.

Isso é tudo que precisamos fazer para enviar mensagens para a rede. Agora, a pergunta é: o que acontece com essas mensagens? Onde elas são manipuladas?

No caso de um aplicativo ponto a ponto, lembre-se de que somos ambos o Sender (remetente) e o Receiver (destinatário) dos eventos, portanto, precisamos lidar tanto com os eventos enviados quanto recebidos em nossa implementação.

Respondendo mensagens com a libp2p

Esta é finalmente a parte onde nosso RecipeBehaviour entra. Vamos defini-lo:

#[derive(NetworkBehaviour)]
struct RecipeBehaviour {
    floodsub: Floodsub,
    mdns: TokioMdns,
    #[behaviour(ignore)]
    response_sender: mpsc::UnboundedSender<ListResponse>,
}
Enter fullscreen mode Exit fullscreen mode

O comportamento em si é simplesmente uma estrutura, mas nós usamos o macro de derivação NetworkBehaviour da libp2p, para que não tenhamos que implementar manualmente todas as funções características nós mesmos.

O macro de derivação implementa as características NetworkBehaviour das funções para todos os membros da estrutura, que não estão anotados com behaviour(ignore). Nosso canal é ignorado aqui porque ele não tem nada a ver com o nosso comportamento diretamente.

O que resta é implementar a função inject_event para ambos FloodsubEvent e MdnsEvent.

Vamos começar com mDNS:

impl NetworkBehaviourEventProcess<MdnsEvent> for RecipeBehaviour {
    fn inject_event(&mut self, event: MdnsEvent) {
        match event {
            MdnsEvent::Discovered(discovered_list) => {
                for (peer, _addr) in discovered_list {
                    self.floodsub.add_node_to_partial_view(peer);
                }
            }
            MdnsEvent::Expired(expired_list) => {
                for (peer, _addr) in expired_list {
                    if !self.mdns.has_node(&peer) {
                        self.floodsub.remove_node_from_partial_view(&peer);
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A função inject_event é chamada quando um evento entra para esse manipulador. No lado do mDNS, existem apenas dois eventos, Discovered e Expired, que são disparados quando vemos um novo par na rede ou quando um par existente desaparece. Em ambos os casos, ou nós adicionamos ou removemos ele da nossa “visão parcial” do FloodSub, que é uma lista de nós para os quais propagamos nossas mensagens.

O inject_event para eventos pub/sub (eventos de produção e consumo) é um pouco mais complexo. Precisamos reagir tanto a cargas recebidas ListRequest quanto a cargas úteis (payloads) ListResponse. Se enviarmos uma ListRequest, o par que recebe essa solicitação buscará seu local, receitas publicadas e então precisará de uma maneira de enviá-los de volta.

A única maneira de enviá-los de volta ao par solicitante é publicá-los na rede. Como o pub/sub é de fato o único mecanismo que temos, precisamos reagir tanto às solicitações recebidas quanto às respostas recebidas.

Vejamos como isso funciona:

impl NetworkBehaviourEventProcess<FloodsubEvent> for RecipeBehaviour {
    fn inject_event(&mut self, event: FloodsubEvent) {
        match event {
            FloodsubEvent::Message(msg) => {
                if let Ok(resp) = serde_json::from_slice::<ListResponse>(&msg.data) {
                    if resp.receiver == PEER_ID.to_string() {
                        info!("Response from {}:", msg.source);
                        resp.data.iter().for_each(|r| info!("{:?}", r));
                    }
                } else if let Ok(req) = serde_json::from_slice::<ListRequest>(&msg.data) {
                    match req.mode {
                        ListMode::ALL => {
                            info!("Received ALL req: {:?} from {:?}", req, msg.source);
                            respond_with_public_recipes(
                                self.response_sender.clone(),
                                msg.source.to_string(),
                            );
                        }
                        ListMode::One(ref peer_id) => {
                            if peer_id == &PEER_ID.to_string() {
                                info!("Received req: {:?} from {:?}", req, msg.source);
                                respond_with_public_recipes(
                                    self.response_sender.clone(),
                                    msg.source.to_string(),
                                );
                            }
                        }
                    }
                }
            }
            _ => (),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Correspondemos à mensagem recebida, tentando desserializá-la para uma solicitação ou resposta. No caso de uma resposta, nós simplesmente imprimimos a resposta com a ID do par do chamador, que obtemos usando msg.source. Quando recebemos uma solicitação, precisamos diferenciar entre os casos ALL e One.

No caso One, verificamos se a ID do par fornecida é a mesma que a nossa - se a solicitação é realmente para nós. Se for, retornamos nossas receitas publicadas, que é nossa resposta no caso de ALL da mesma forma.

Em ambos os casos, chamamos o auxiliar respond_with_public_recipes:

fn respond_with_public_recipes(sender: mpsc::UnboundedSender<ListResponse>, receiver: String) {
    tokio::spawn(async move {
        match read_local_recipes().await {
            Ok(recipes) => {
                let resp = ListResponse {
                    mode: ListMode::ALL,
                    receiver,
                    data: recipes.into_iter().filter(|r| r.public).collect(),
                };
                if let Err(e) = sender.send(resp) {
                    error!("error sending response via channel, {}", e);
                }
            }
            Err(e) => error!("error fetching local recipes to answer ALL request, {}", e),
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Neste método auxiliar, usamos a geração da Tokio para executar assincronamente um futuro, que lê todas as receitas locais, cria uma ListResponse a partir dos dados e envia esses dados por meio do channel_sender para o nosso loop de eventos, onde lidamos com ele desta maneira:

               EventType::Response(resp) => {
                    let json = serde_json::to_string(&resp).expect("can jsonify response");
                    swarm.floodsub.publish(TOPIC.clone(), json.as_bytes());
                }
Enter fullscreen mode Exit fullscreen mode

Se notarmos um evento Response enviado “internamente”, nós o serializamos para JSON e enviamos para a rede.

Testando com a libp2p

Isso é tudo para a implementação. Agora, vamos testá-la.

Para verificar se nossa implementação funciona, vamos iniciar o aplicativo em diversos terminais usando este comando:

RUST_LOG=info cargo run
Enter fullscreen mode Exit fullscreen mode

Mantenha em mente que o aplicativo espera um arquivo chamado recipes.json no diretório da onde você o está inicializando.

Quando o aplicativo é iniciado, obtemos o seguinte registro, imprimindo nossa ID de par:

INFO  rust_peer_to_peer_example > Peer Id: 12D3KooWDc1FDabQzpntvZRWeDZUL351gJRy3F4E8VN5Gx2pBCU2
Enter fullscreen mode Exit fullscreen mode

Agora precisamos pressionar “enter” para iniciar o loop de eventos.

Ao entrar em ls p, obtemos uma lista de nossos pares descobertos:

ls p
 INFO  rust_peer_to_peer_example > Discovered Peers:
 INFO  rust_peer_to_peer_example > 12D3KooWCK6X7mFk9HeWw69WF1ueWa3XmphZ2Mu7ZHvEECj5rrhG
 INFO  rust_peer_to_peer_example > 12D3KooWLGN85pv5XTDALGX5M6tRgQtUGMWXWasWQD6oJjMcEENA
Enter fullscreen mode Exit fullscreen mode

Com ls r, obtemos as receitas locais:

ls r
 INFO  rust_peer_to_peer_example > Local Recipes (3)
 INFO  rust_peer_to_peer_example > Recipe { id: 0, name: " Coffee", ingredients: "Coffee", instructions: "Make Coffee", public: true }
 INFO  rust_peer_to_peer_example > Recipe { id: 1, name: " Tea", ingredients: "Tea, Water", instructions: "Boil Water, add tea", public: false }
 INFO  rust_peer_to_peer_example > Recipe { id: 2, name: " Carrot Cake", ingredients: "Carrots, Cake", instructions: "Make Carrot Cake", public: true }
Enter fullscreen mode Exit fullscreen mode

A chamada ls r all aciona o envio de uma solicitação para outros pares e retorna suas receitas:

ls r all
 INFO  rust_peer_to_peer_example > Response from 12D3KooWCK6X7mFk9HeWw69WF1ueWa3XmphZ2Mu7ZHvEECj5rrhG:
 INFO  rust_peer_to_peer_example > Recipe { id: 0, name: " Coffee", ingredients: "Coffee", instructions: "Make Coffee", public: true }
 INFO  rust_peer_to_peer_example > Recipe { id: 2, name: " Carrot Cake", ingredients: "Carrots, Cake", instructions: "Make Carrot Cake", public: true }
Enter fullscreen mode Exit fullscreen mode

O mesmo acontece se usarmos ls r com uma ID de par:

ls r 12D3KooWCK6X7mFk9HeWw69WF1ueWa3XmphZ2Mu7ZHvEECj5rrhG
 INFO  rust_peer_to_peer_example > Response from 12D3KooWCK6X7mFk9HeWw69WF1ueWa3XmphZ2Mu7ZHvEECj5rrhG:
 INFO  rust_peer_to_peer_example > Recipe { id: 0, name: " Coffee", ingredients: "Coffee", instructions: "Make Coffee", public: true }
 INFO  rust_peer_to_peer_example > Recipe { id: 2, name: " Carrot Cake", ingredients: "Carrots, Cake", instructions: "Make Carrot Cake", public: true }
Enter fullscreen mode Exit fullscreen mode

Funciona! Você também pode tentar isso com uma grande quantidade de clientes na mesma rede.

Você pode encontrar o código de exemplo completo no GitHub.

Conclusão

Neste post, abordamos como construir um aplicativo de rede pequeno e descentralizado usando o Rust e a libp2p.

Se você vem de uma experiência na Web, muitos dos conceitos de rede serão um tanto familiares, mas a construção de um aplicativo ponto a ponto ainda demanda uma abordagem fundamentalmente diferente para projetar e construir.

A biblioteca libp2p é bastante madura e, devido à popularidade do Rust na cena de criptografia, existe um rico ecossistema emergente de bibliotecas para construir aplicativos descentralizados poderosos.

LogRocket: visibilidade total em front-ends da Web para aplicativos Rust

A depuração de aplicativos Rust podem ser difíceis, especialmente quando os usuários vivenciam problemas difíceis de reproduzir. Se você estiver interessado em monitorar e rastrear o desempenho de seus aplicativos Rust, identificando erros automaticamente e rastreando solicitações de rede lentas e tempo de carregamento, experimente o LogRocket.

O LogRocket é como um DVR para Web e aplicativos móveis, gravando literalmente tudo o que acontece em seu aplicativo Rust. Em vez de adivinhar por que os problemas acontecem, você pode agregar e relatar em que estado seu aplicativo estava quando ocorreu um problema. O LogRocket também monitora o desempenho do seu aplicativo, relatando métricas como a carga da CPU do cliente, uso de memória do cliente e muito mais.

Modernize a forma como você depura seus aplicativos Rust - comece a monitorar gratuitamente.

Esse artigo foi escrito por Mario Zupan e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Top comments (0)