O comprimento de uma string Java compactada não é igual ao comprimento do conteúdo quando ela é enviada como uma mensagem WebSocket

Sep 18 2020

Estou tentando reduzir o consumo de largura de banda compactando o JSON Stringque estou enviando por meio do WebSocket do meu aplicativo Springboot para o cliente do navegador (isso é sobre a permessage-deflateextensão WebSocket). Este cenário usa o seguinte JSON, Stringque tem um comprimento de 383 caracteres:

{"headers":{},"body":{"message":{"errors":{"password":"Password length must be at least 8 characters.","retype":"Retype Password cannot be null.","username":"Username length must be between 6 to 64 characters."},"links":[],"success":false,"target":{"password":"","retype":"","username":""}},"target":"/user/session/signup"},"statusCode":"UNPROCESSABLE_ENTITY","statusCodeValue":422}

Para comparar, eu envio String compactada e descompactada do servidor assim:

Object response = …,

SimpMessageHeaderAccessor simpHeaderAccessor =
    SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
simpHeaderAccessor.setSessionId(sessionId);
simpHeaderAccessor.setContentType(new MimeType("application", "json",
    StandardCharsets.UTF_8));
simpHeaderAccessor.setLeaveMutable(true);
// Sends the uncompressed message.
messagingTemplate.convertAndSendToUser(sessionId, uri, response,
    simpHeaderAccessor.getMessageHeaders());

ObjectMapper mapper = new ObjectMapper();
String jsonString;

try {
    jsonString = mapper.writeValueAsString(response);
}
catch(JsonProcessingException e) {
    jsonString = response.toString();
}

log.info("The payload is application/json.");
log.info("uncompressed payload (" + jsonString.length() + " character):");
log.info(jsonString);

String lzStringCompressed = LZString.compress(jsonString);
simpHeaderAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
simpHeaderAccessor.setSessionId(sessionId);
simpHeaderAccessor.setContentType(new MimeType("text", "plain",
    StandardCharsets.UTF_8));
simpHeaderAccessor.setLeaveMutable(true);
// Sends the compressed message.
messagingTemplate.convertAndSendToUser(sessionId, uri, lzStringCompressed,
    simpHeaderAccessor.getMessageHeaders());

log.info("The payload is text/plain.");
log.info("compressed payload (" + lzStringCompressed.length() + " character):");
log.info(lzStringCompressed);

Que registra as seguintes linhas no console Java:

The payload is application/json.
uncompressed payload (383 character):
{"headers":{},"body":{"message":{"errors":{"password":"Password length must be at least 8 characters.","retype":"Retype Password cannot be null.","username":"Username length must be between 6 to 64 characters."},"links":[],"success":false,"target":{"password":"","retype":"","username":""}},"target":"/user/session/signup"},"statusCode":"UNPROCESSABLE_ENTITY","statusCodeValue":422}
The payload is text/plain.
compressed payload (157 character):
??????????¼??????????????p??!-??7??????????????????????????????????u??????????????????????·}???????????????????????????????????????/?┬R??b,??????m??????????

Em seguida, o navegador recebe as duas mensagens enviadas pelo servidor e capturadas por este javascript:

stompClient.connect({}, function(frame) {
    stompClient.subscribe(stompClientUri, function(payload) {
        try {
            JSON.parse(payload.body);
            console.log("The payload is application/json.");
            console.log("uncompressed payload (" + payload.body.length + " character):");
            console.log(payload.body);

            payload = JSON.parse(payload.body);
        } catch (e) {
            try {
                payload = payload.body;
                console.log("The payload is text/plain.");
                console.log("compressed payload (" + payload.length + " character):");
                console.log(payload);

                var decompressPayload = LZString.decompress(payload);
                console.log("decompressed payload (" + decompressPayload.length + " character):");
                console.log(decompressPayload);

                payload = JSON.parse(decompressPayload);
            } catch (e) {
            } finally {
            }
        } finally {
        }
    });
});

Que exibe as seguintes linhas no console de depuração do navegador:

The payload is application/json.
uncompressed payload (383 character):
{"headers":{},"body":{"message":{"errors":{"password":"Password length must be at least 8 characters.","retype":"Retype Password cannot be null.","username":"Username length must be between 6 to 64 characters."},"links":[],"success":false,"target":{"password":"","retype":"","username":""}},"target":"/user/session/sign-up"},"statusCode":"UNPROCESSABLE_ENTITY","statusCodeValue":422}
The payload is text/plain.
compressed payload (157 character):
ᯡࠥ䅬ࢀጨᎡ乀ஸ̘͢¬ߑ䁇啰˸⑱ᐣ䱁ሢ礒⽠݉ᐮ皆⩀p瑭漦!-䈠ᷕ7ᡑ刡⺨狤灣મ啃嵠ܸ䂃ᡈ硱䜄ቀρۯĮニᴴဠ䫯⻖֑点⇅劘畭ᣔ奢⅏㛥⡃Ⓛ撜u≂㥋╋ၲ⫋䋕᪒丨ಸ䀭䙇Ꮴ吠塬昶⬻㶶Т㚰ͻၰú}㙂᥸沁⠈ƹ⁄᧸㦓ⴼ䶨≋愐㢡ᱼ溜涤簲╋㺮橿䃍砡瑧ᮬ敇⼺ℙ滆䠢榵ⱀ盕ີ‣Ш眨રą籯/ሤÂR儰Ȩb,帰Ћ愰䀥․䰂m㛠ளǀ䀭❖⧼㪠Ө柀䀠 
decompressed payload (383 character):
{"headers":{},"body":{"message":{"errors":{"password":"Password length must be at least 8 characters.","retype":"Retype Password cannot be null.","username":"Username length must be between 6 to 64 characters."},"links":[],"success":false,"target":{"password":"","retype":"","username":""}},"target":"/user/session/sign-up"},"statusCode":"UNPROCESSABLE_ENTITY","statusCodeValue":422}

Nesse ponto, posso verificar se qualquer Stringvalor que meu aplicativo Springboot comprime, o navegador pode descompactar e obter o original String. Porém, há um problema. Quando inspecionei o depurador do navegador para ver se o tamanho da mensagem transferida foi realmente reduzido, ele me disse que não.

Aqui está a mensagem não compactada bruta (598B):

a["MESSAGE destination:/user/session/broadcast
content-type:application/json;charset=UTF-8
subscription:sub-0
message-id:5lrv4kl1-1
content-length:383

{"headers":{},"body":{"message":{"errors":{"password":"Password length must be at least 8 characters.","retype":"Retype Password cannot be null.","username":"Username length must be between 6 to 64 characters."},"links":[],"success":false,"target":{"password":"","retype":"","username":""}},"target":"/user/session/sign-up"},"statusCode":"UNPROCESSABLE_ENTITY","statusCodeValue":422}

Embora esta seja a mensagem compactada bruta (589B):

a["MESSAGE destination:/user/session/broadcast
content-type:text/plain;charset=UTF-8
subscription:sub-0
message-id:5lrv4kl1-2
content-length:425

á¯¡à ¥ä¬à¢á¨á¡ä¹à®¸Ì͢¬ßäå°Ë¸â±á£ä±á¢ç¤â½Ýá®çâ©pç­æ¼¦!-ä á·7á¡å¡âº¨ç¤ç£àª®ååµÜ¸äá¡ç¡±äáÏۯĮãá´´á䫯â»Öç¹âåç­á£å¥¢âã¥â¡âæuâã¥âá²â«äáªä¸¨à²¸ä­äá¤å塬æ¶â¬»ã¶¶Ð¢\u2029ã°Í»á°Ãº}ã᥸æ²âƹâ᧸ã¦â´¼ä¶¨âæã¢¡á±¼æºæ¶¤ç°²â㺮橿äç¡ç§á®¬æâ¼ºâæ»ä¢æ¦µâ±çີâ£Ð¨ç¨àª°Ä籯/á¤ÃRå°È¨b,å¸°Ðæ°ä¥â¤ä°mãளÇä­â⧼㪠Өæä  \u0000"]

O console de depuração indica que a mensagem descompactada foi transferida com o tamanho de 598B, com 383 caracteres como o tamanho da carga útil da mensagem (indicado pelo content-lengthcabeçalho). Por outro lado, a mensagem compactada foi transferida com um tamanho total de 589B, 9B menor do que a não compactada, com 425 caracteres como o tamanho da carga útil da mensagem. Tenho várias perguntas:

  1. O content-lengthda mensagem STOMP é indicado em bytes ou em caracteres?
  2. Por que o content-lengthda mensagem não compactada, que é 383, é menor do que o da mensagem compactada, que é 425?
  3. Isso significa que reduzir o comprimento dos caracteres nem sempre significa necessariamente reduzir o tamanho?
  4. Por que o content-lengthda mensagem compactada, que é 425, não é o mesmo que o valor retornado no console Java (usando lzStringCompressed.length()) que é 157, considerando que a mensagem descompactada foi transferida com um content-lengthde 383, que tem o mesmo comprimento no console Java . Ambos também são transferidos com charset=UTF-8codificação.
  5. Por que o content-lengthda mensagem compactada, que é 425, não é o mesmo valor retornado no console Java (usando lzStringCompressed.length()) que é 157, mas o código JavaScript payload.lengthretorna 157, não 425?
  6. Se ele realmente fica inchado durante a transferência, por que a mensagem com application/jsonnão foi afetada e apenas o plain/textfica inchado?

Embora a diferença 9B ainda seja uma diferença, estou reconsiderando se vale a pena manter o custo indireto para compactar / descompactar a mensagem. Tenho que testar outros Stringvalores para isso.

Respostas

4 jccampanero Sep 24 2020 at 05:10

Todas as questões estão intimamente relacionadas.

  1. O content-lengthda mensagem STOMP é indicado em bytes ou em caracteres?

Como você pode ver na especificação STOMP :

Todos os frames PODEM incluir um content-lengthcabeçalho. Este cabeçalho é uma contagem de octetos para o comprimento do corpo da mensagem ....

De uma perspectiva STOMP, o corpo é uma matriz de bytes e os cabeçalhos content-typee content-lengthdetermina o que o corpo contém e como deve ser interpretado.

  1. Por que o content-lengthda mensagem não compactada, que é 383menor do que o da mensagem compactada, que é 425?

Por causa da conversão para a UTF-8qual é realizada quando você envia a informação ao cliente em seu servidor STOMP.

Você tem uma mensagem, a String, e essa mensagem é composta por uma série de personagens.

Sem entrar em grandes detalhes - por favor, revise esta ou esta outra resposta excelente se precisar de mais informações - internamente , tudo charem Java é representado em unidades de código Unicode.

Para representar essas unidades de código Unicode em um determinado conjunto de caracteres, UTF-8no seu caso, pode ser necessário um número variável de bytes, de um a quatro no seu caso específico.

No caso da mensagem descompactada, você tem 383 chars, ASCII puro, que será codificado UTF-8com um bytepor char. É por isso que você obtém o mesmo valor no content-lengthcabeçalho.

Mas não é o caso da mensagem compactada: ao compactar sua mensagem, ela lhe dará um número arbitrário de bytes, correspondente a 157 chars - unidades de código Unicode - com informações arbitrárias. O número de bytes obtidos será menor que a mensagem original. Mas então você o codifica em UTF-8. Alguns desses 157 chars serão representados por um byte, como era o caso da mensagem original, mas devido à arbitrariedade das informações da mensagem compactada é mais provável que, em muitos casos, dois, três ou quatro bytes sejam necessários para representam alguns deles. Esta é a razão pela qual você obtém um número de bytes maior que o número de bytes da mensagem descompactada.

  1. Isso significa que reduzir o comprimento dos caracteres nem sempre significa necessariamente reduzir o tamanho?

Em geral, você sempre obterá um tamanho pequeno de informações ao compactar seus dados.

Se as informações forem suficientes para fazer o uso da compactação valer a pena, e você tiver a capacidade de enviar as informações binárias brutas compactadas - semelhante a quando um servidor envia informações indicando Content-Encoding: gzipou deflate, isso pode lhe trazer um grande benefício.

Mas se a biblioteca cliente pudesse manipular apenas mensagens de texto e não binárias, como SockJS por exemplo, como você pode ver, o problema de codificação pode, na verdade, fornecer resultados inadequados.

Para atenuar o problema, você pode primeiro tentar compactar suas informações em outras codificações intermediárias, como Base 64, o que lhe dará aproximadamente 1.6vezes o número de bytes compactados: se esse valor for menor que o número de bytes sem compactação, compactar a mensagem pode valer a pena isto.

Em qualquer caso, conforme indicado na especificação, STOMP é baseado em texto, mas também permite a transmissão de mensagens binárias. Além disso, indica que a codificação padrão para STOMP é UTF-8, mas suporta a especificação de codificações alternativas para corpos de mensagens.

Se você estiver usando, como seu código sugere, stomp-js- por favor, esteja ciente de que eu não usei essa biblioteca, como a documentação indica, parece possível processar mensagens binárias também.

Basicamente, seu servidor deve enviar as informações de bytes brutos com um content-typecabeçalho com valor application/octet-stream.

Essas informações podem ser processadas no lado do cliente pela biblioteca com algo semelhante a este:

    // within message callback
    if (message.headers['content-type'] === 'application/octet-stream') {
      // message is binary
      // call message.binaryBody 
    } else {
      // message is text
      // call message.body
    }

Se funcionar, e você puder enviar as informações compactadas dessa forma, conforme indicado anteriormente, a compactação pode trazer um grande benefício.

  1. Por que o content-lengthda mensagem compactada, que é 425diferente do valor retornado no console Java (usando lzStringCompressed.length()), que é 157, considerando que a mensagem descompactada foi transferida com um content-lengthde 383, que tem o mesmo comprimento no console Java. Ambos também são transferidos com charset=UTF-8 encoding.

Considere o Javadoc do lengthmétodo da Stringclasse:

Retorna o comprimento desta string. O comprimento é igual ao número de unidades de código Unicode na string.

Como você pode ver, o lengthmétodo fornecerá o número de unidades de código Unicode necessárias para representar o String, enquanto o content-lengthcabeçalho fornecerá o número de bytes necessários para representá-los UTF-8conforme indicado anteriormente.

Na verdade, calcular o comprimento da corda pode ser uma tarefa complicada .

  1. Por que o content-lengthda mensagem compactada, que 425não é o mesmo valor retornado no console Java (usando lzStringCompressed.length()), que é 157apenas o código JavaScript payload.length retorna 157, não 425?

Pois, como você pode ver na documentação , lengthem Javascript também indica o comprimento do Stringobjeto em UTF-16unidades de código:

A lengthpropriedade de um Stringobjeto contém o comprimento da string, em UTF-16unidades de código. lengthé uma propriedade de dados somente leitura de instâncias de string.

  1. Se ele realmente fica inchado durante a transferência, por que a mensagem com application/jsonnão foi afetada e apenas o text/plainfica inchado?

Como mencionado acima, não tem nada a ver com a, Content-Typemas com a codificação das informações.