Skip to content

Commit 2b38c86

Browse files
authored
Merge pull request #1385 from magicprinc/feature/Headers
Headers +toString(), fixes
2 parents 10fe228 + 9f528a2 commit 2b38c86

File tree

2 files changed

+133
-39
lines changed

2 files changed

+133
-39
lines changed

src/main/java/io/nats/client/impl/Headers.java

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2020 The NATS Authors
1+
// Copyright 2020-2025 The NATS Authors
22
// Licensed under the Apache License, Version 2.0 (the "License");
33
// you may not use this file except in compliance with the License.
44
// You may obtain a copy of the License at:
@@ -14,12 +14,12 @@
1414
package io.nats.client.impl;
1515

1616
import io.nats.client.support.ByteArrayBuilder;
17+
import org.jspecify.annotations.Nullable;
1718

19+
import java.nio.charset.StandardCharsets;
1820
import java.util.*;
1921
import java.util.function.BiConsumer;
20-
2122
import static io.nats.client.support.NatsConstants.*;
22-
import static java.nio.charset.StandardCharsets.US_ASCII;
2323

2424
/**
2525
* An object that represents a map of keys to a list of values. It does not accept
@@ -31,13 +31,13 @@
3131
public class Headers {
3232

3333
private static final String KEY_CANNOT_BE_EMPTY_OR_NULL = "Header key cannot be null.";
34-
private static final String KEY_INVALID_CHARACTER = "Header key has invalid character: ";
35-
private static final String VALUE_INVALID_CHARACTERS = "Header value has invalid character: ";
34+
private static final String KEY_INVALID_CHARACTER = "Header key has invalid character: 0x";
35+
private static final String VALUE_INVALID_CHARACTERS = "Header value has invalid character: 0x";
3636

3737
private final Map<String, List<String>> valuesMap;
3838
private final Map<String, Integer> lengthMap;
3939
private final boolean readOnly;
40-
private byte[] serialized;
40+
private byte @Nullable [] serialized;
4141
private int dataLength;
4242

4343
public Headers() {
@@ -52,7 +52,7 @@ public Headers(Headers headers, boolean readOnly) {
5252
this(headers, readOnly, null);
5353
}
5454

55-
public Headers(Headers headers, boolean readOnly, String[] keysNotToCopy) {
55+
public Headers(@Nullable Headers headers, boolean readOnly, String @Nullable [] keysNotToCopy) {
5656
Map<String, List<String>> tempValuesMap = new HashMap<>();
5757
Map<String, Integer> tempLengthMap = new HashMap<>();
5858
if (headers != null) {
@@ -197,8 +197,8 @@ public Headers put(Map<String, List<String>> map) {
197197
if (map == null || map.isEmpty()) {
198198
return this;
199199
}
200-
for (String key : map.keySet() ) {
201-
_put(key, map.get(key));
200+
for (Map.Entry<String, List<String>> entry : map.entrySet()) {
201+
_put(entry.getKey(), entry.getValue());
202202
}
203203
return this;
204204
}
@@ -331,7 +331,7 @@ public Set<String> keySet() {
331331
* @return a read-only set of keys (in lowercase) contained in this map
332332
*/
333333
public Set<String> keySetIgnoreCase() {
334-
HashSet<String> set = new HashSet<>();
334+
HashSet<String> set = new HashSet<>();// no capacity is OK for small maps
335335
for (String k : valuesMap.keySet()) {
336336
set.add(k.toLowerCase());
337337
}
@@ -345,7 +345,7 @@ public Set<String> keySetIgnoreCase() {
345345
* @param key the key whose associated value is to be returned
346346
* @return a read-only list of the values for the case-sensitive key.
347347
*/
348-
public List<String> get(String key) {
348+
public @Nullable List<String> get(String key) {
349349
List<String> values = valuesMap.get(key);
350350
return values == null ? null : Collections.unmodifiableList(values);
351351
}
@@ -356,7 +356,7 @@ public List<String> get(String key) {
356356
* @param key the key whose associated value is to be returned
357357
* @return the first value for the case-sensitive key.
358358
*/
359-
public String getFirst(String key) {
359+
public @Nullable String getFirst(String key) {
360360
List<String> values = valuesMap.get(key);
361361
return values == null ? null : values.get(0);
362362
}
@@ -368,7 +368,7 @@ public String getFirst(String key) {
368368
* @param key the key whose associated value is to be returned
369369
* @return the last value for the case-sensitive key.
370370
*/
371-
public String getLast(String key) {
371+
public @Nullable String getLast(String key) {
372372
List<String> values = valuesMap.get(key);
373373
return values == null ? null : values.get(values.size() - 1);
374374
}
@@ -380,11 +380,11 @@ public String getLast(String key) {
380380
* @param key the key whose associated value is to be returned
381381
* @return a read-only list of the values for the case-insensitive key.
382382
*/
383-
public List<String> getIgnoreCase(String key) {
383+
public @Nullable List<String> getIgnoreCase(String key) {
384384
List<String> values = new ArrayList<>();
385-
for (String k : valuesMap.keySet()) {
386-
if (k.equalsIgnoreCase(key)) {
387-
values.addAll(valuesMap.get(k));
385+
for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
386+
if (entry.getKey().equalsIgnoreCase(key)) {
387+
values.addAll(entry.getValue());
388388
}
389389
}
390390
return values.isEmpty() ? null : Collections.unmodifiableList(values);
@@ -401,7 +401,9 @@ public List<String> getIgnoreCase(String key) {
401401
* removed during iteration
402402
*/
403403
public void forEach(BiConsumer<String, List<String>> action) {
404-
Collections.unmodifiableMap(valuesMap).forEach(action);
404+
for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
405+
action.accept(entry.getKey(), Collections.unmodifiableList(entry.getValue()));
406+
}
405407
}
406408

407409
/**
@@ -460,9 +462,9 @@ public byte[] getSerialized() {
460462
@Deprecated
461463
public ByteArrayBuilder appendSerialized(ByteArrayBuilder bab) {
462464
bab.append(HEADER_VERSION_BYTES_PLUS_CRLF);
463-
for (String key : valuesMap.keySet()) {
464-
for (String value : valuesMap.get(key)) {
465-
bab.append(key);
465+
for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
466+
for (String value : entry.getValue()) {
467+
bab.append(entry.getKey());
466468
bab.append(COLON_BYTES);
467469
bab.append(value);
468470
bab.append(CRLF_BYTES);
@@ -474,27 +476,28 @@ public ByteArrayBuilder appendSerialized(ByteArrayBuilder bab) {
474476

475477
/**
476478
* Write the header to the byte array. Assumes that the caller has
477-
* already validated that the destination array is large enough by using getSerialized()
479+
* already validated that the destination array is large enough by using {@link #getSerialized()}.
480+
* <p>/Deprecated {@link String#getBytes(int, int, byte[], int)} is used, because it still exists in JDK 25
481+
* and is 10–30 times faster than {@code getBytes(ISO_8859_1/US_ASCII)}/
478482
* @param destPosition the position index in destination byte array to start
479483
* @param dest the byte array to write to
480484
* @return the length of the header
481485
*/
486+
@SuppressWarnings("deprecation")
482487
public int serializeToArray(int destPosition, byte[] dest) {
483488
System.arraycopy(HEADER_VERSION_BYTES_PLUS_CRLF, 0, dest, destPosition, HVCRLF_BYTES);
484489
destPosition += HVCRLF_BYTES;
485490

486491
for (Map.Entry<String, List<String>> entry : valuesMap.entrySet()) {
487-
List<String> values = entry.getValue();
488-
for (String value : values) {
489-
byte[] bytes = entry.getKey().getBytes(US_ASCII);
490-
System.arraycopy(bytes, 0, dest, destPosition, bytes.length);
491-
destPosition += bytes.length;
492+
String key = entry.getKey();
493+
for (String value : entry.getValue()) {
494+
key.getBytes(0, key.length(), dest, destPosition);// key has only US_ASCII
495+
destPosition += key.length();
492496

493497
dest[destPosition++] = COLON;
494498

495-
bytes = value.getBytes(US_ASCII);
496-
System.arraycopy(bytes, 0, dest, destPosition, bytes.length);
497-
destPosition += bytes.length;
499+
value.getBytes(0, value.length(), dest, destPosition);
500+
destPosition += value.length();
498501

499502
dest[destPosition++] = CR;
500503
dest[destPosition++] = LF;
@@ -503,6 +506,7 @@ public int serializeToArray(int destPosition, byte[] dest) {
503506
dest[destPosition++] = CR;
504507
dest[destPosition] = LF;
505508

509+
//to do update serialized and/or dataLength?
506510
return serializedLength();
507511
}
508512

@@ -512,7 +516,7 @@ public int serializeToArray(int destPosition, byte[] dest) {
512516
* @throws IllegalArgumentException if the key is null, empty or contains
513517
* an invalid character
514518
*/
515-
private void checkKey(String key) {
519+
static void checkKey(String key) {
516520
// key cannot be null or empty and contain only printable characters except colon
517521
if (key == null || key.isEmpty()) {
518522
throw new IllegalArgumentException(KEY_CANNOT_BE_EMPTY_OR_NULL);
@@ -522,7 +526,7 @@ private void checkKey(String key) {
522526
for (int idx = 0; idx < len; idx++) {
523527
char c = key.charAt(idx);
524528
if (c < 33 || c > 126 || c == ':') {
525-
throw new IllegalArgumentException(KEY_INVALID_CHARACTER + "'" + c + "'");
529+
throw new IllegalArgumentException(KEY_INVALID_CHARACTER + Integer.toHexString(c));
526530
}
527531
}
528532
}
@@ -532,17 +536,18 @@ private void checkKey(String key) {
532536
*
533537
* @throws IllegalArgumentException if the value contains an invalid character
534538
*/
535-
private void checkValue(String val) {
539+
static void checkValue(String val) {
536540
// Like rfc822 section 3.1.2 (quoted in ADR 4)
537541
// The field-body may be composed of any US-ASCII characters, except CR or LF.
538-
val.chars().forEach(c -> {
542+
for (int i = 0, len = val.length(); i < len; i++) {
543+
int c = val.charAt(i);
539544
if (c > 127 || c == 10 || c == 13) {
540-
throw new IllegalArgumentException(VALUE_INVALID_CHARACTERS + c);
545+
throw new IllegalArgumentException(VALUE_INVALID_CHARACTERS + Integer.toHexString(c));
541546
}
542-
});
547+
}
543548
}
544549

545-
private class Checker {
550+
private static final class Checker {
546551
List<String> list = new ArrayList<>();
547552
int len = 0;
548553

@@ -581,13 +586,29 @@ public boolean isReadOnly() {
581586
@Override
582587
public boolean equals(Object o) {
583588
if (this == o) return true;
584-
if (o == null || getClass() != o.getClass()) return false;
589+
if (!(o instanceof Headers)) return false;
585590
Headers headers = (Headers) o;
586591
return Objects.equals(valuesMap, headers.valuesMap);
587592
}
588593

589594
@Override
590595
public int hashCode() {
591-
return Objects.hash(valuesMap);
596+
return Objects.hashCode(valuesMap);
597+
}
598+
599+
@Override
600+
public String toString() {
601+
byte[] b = getSerialized();
602+
int len = b.length;
603+
if (len <= HVCRLF_BYTES + 2){
604+
return "";// empty map
605+
}
606+
for (int i = 0; i < len; i++) {
607+
switch (b[i]) {
608+
case CR: b[i] = ';'; break;
609+
case LF: b[i] = ' '; break;
610+
}
611+
}
612+
return new String(b, HVCRLF_BYTES, len - HVCRLF_BYTES - 3, StandardCharsets.ISO_8859_1);// b has only US_ASCII, ISO_8859_1 is 3x faster
592613
}
593614
}

src/test/java/io/nats/client/impl/HeadersTests.java

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.nats.client.support.Status;
55
import io.nats.client.support.Token;
66
import io.nats.client.support.TokenType;
7+
import org.junit.jupiter.api.Disabled;
78
import org.junit.jupiter.api.Test;
89

910
import java.nio.charset.StandardCharsets;
@@ -217,7 +218,13 @@ public void testReadOnly() {
217218
assertTrue(headers1.isReadOnly());
218219
assertThrows(UnsupportedOperationException.class, () -> headers1.put(KEY1, VAL2));
219220
assertThrows(UnsupportedOperationException.class, () -> headers1.put(KEY1, VAL2));
221+
assertThrows(UnsupportedOperationException.class, () -> headers1.put(KEY1, VAL1, VAL2));
222+
assertThrows(UnsupportedOperationException.class, () -> headers1.put(KEY1, Arrays.asList(VAL1, VAL2)));
220223
assertThrows(UnsupportedOperationException.class, () -> headers1.remove(KEY1));
224+
assertThrows(UnsupportedOperationException.class, () -> headers1.remove(KEY1,KEY2));
225+
assertThrows(UnsupportedOperationException.class, () -> headers1.remove(Arrays.asList(KEY1,KEY2)));
226+
assertThrows(UnsupportedOperationException.class, () -> headers1.add(KEY1, VAL2));
227+
assertThrows(UnsupportedOperationException.class, () -> headers1.add(KEY1, Arrays.asList(VAL1, VAL2)));
221228
assertThrows(UnsupportedOperationException.class, headers1::clear);
222229
assertEquals(VAL1, headers1.getFirst(KEY1));
223230
}
@@ -761,6 +768,18 @@ public void testTokenSamePoint() {
761768
@Test
762769
public void testToString() {
763770
assertNotNull(new Status(1, "msg").toString()); // COVERAGE
771+
772+
Headers h = new Headers();
773+
assertEquals("", h.toString());
774+
775+
h.add("Test1");
776+
h.add("Test2", "Test2Value");
777+
h.add("Test3", "");
778+
h.add("Test4", "", "", "");
779+
h.add("Test5", "Nice!", "To.", "See?");
780+
781+
assertEquals("Test5:Nice!; Test5:To.; Test5:See?; Test4:; Test4:; Test4:; Test3:; Test2:Test2Value;",
782+
h.toString());// flaky: non-sorted HashMap
764783
}
765784

766785
@Test
@@ -782,4 +801,58 @@ public void put_map_works() {
782801
assertTrue(h.get(KEY2).contains(VAL3));
783802
assertEquals(VAL2, h.getFirst(KEY2));
784803
}
804+
805+
@Test
806+
void testForEach() {
807+
Headers h = new Headers();
808+
h.put("test", "a","b","c");
809+
h.forEach((k, v) -> {
810+
assertEquals("test", k);
811+
assertContainsExactly(v, "a", "b", "c");
812+
assertThrows(UnsupportedOperationException.class, ()->v.add("z"));
813+
});
814+
}
815+
816+
/// @see io.nats.client.impl.Headers#checkValue
817+
@Test
818+
void testCheckValue() {
819+
Headers h = new Headers();
820+
h.put("test1", "\u0000 \f\b\t");
821+
822+
assertThrows(IllegalArgumentException.class, ()->h.put("test", "×"));
823+
assertThrows(IllegalArgumentException.class, ()->h.put("test", "\r"));
824+
assertThrows(IllegalArgumentException.class, ()->h.put("test", "\n"));
825+
826+
assertEquals(1, h.size());
827+
assertEquals(1, h.get("test1").size());
828+
assertEquals("\u0000 \f\b\t", h.getFirst("test1"));
829+
}
830+
831+
/**
832+
no JMH :(
833+
Old: Time: 24622.87ms, Op/sec: 4061264
834+
New: Time: 6660.18ms, Op/sec: 15014614
835+
New variant is 15014614/4061264= 3.7 times faster
836+
*/
837+
@Test @Disabled("Benchmark after changes in serializeToArray: Time: 6_660ms, Op/sec: 15_014_614")
838+
void benchmark_serializeToArray() {
839+
Headers h = new Headers().put("test", "aaa", "bBb", "ZZZZZZZZ")
840+
.put("ALongLongLongLongLongLongLongKey", "VeryLongLongLongLongLongLongLongLongLong:Value!");
841+
assertEquals(
842+
"ALongLongLongLongLongLongLongKey:VeryLongLongLongLongLongLongLongLongLong:Value!; test:aaa; test:bBb; test:ZZZZZZZZ;",
843+
h.toString());
844+
845+
byte[] dst = new byte[1000];
846+
for (int i = 0; i < 10_000; i++) {// warm-up
847+
assertEquals(129, h.serializeToArray(0, dst));
848+
}
849+
850+
long t = System.nanoTime();
851+
int max = 100_000_000;
852+
for (int i = 0; i < max; i++) {
853+
h.serializeToArray(0, dst);
854+
}
855+
t = System.nanoTime() - t;
856+
System.out.println("Time: " + t / 1000 / 1000.0 +"ms, Op/sec: "+(max*1_000_000_000L/t));
857+
}
785858
}

0 commit comments

Comments
 (0)