Make spring-analytics-consumer as auto-config

* Fix all the Checkstyle violations
This commit is contained in:
Artem Bilan
2024-01-03 15:20:46 -05:00
parent 8e64a131f7
commit c7b8647258
15 changed files with 111 additions and 125 deletions

View File

@@ -1,7 +1,7 @@
dependencies {
api project(':spring-payload-converter-function')
api 'io.micrometer:micrometer-core'
api 'org.springframework.boot:spring-boot-starter-actuator'
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
testImplementation 'io.micrometer:micrometer-registry-wavefront'
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -27,65 +27,48 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.composite.CompositeMeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.fn.common.config.SpelExpressionConverterConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.expression.EvaluationContext;
import org.springframework.messaging.Message;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* The auto-configuration for analytics consumer.
*
* @author Christian Tzolov
*/
@Configuration
@AutoConfiguration(after = MetricsAutoConfiguration.class)
@EnableConfigurationProperties(AnalyticsConsumerProperties.class)
public class AnalyticsConsumerConfiguration {
/** Default tag value. Used to fill the tag when the actual value is missing. */
/**
* Default tag value. Used to fill the tag when the actual value is missing.
*/
public static final String UNAVAILABLE_TAG = "NA";
private final Map<Meter.Id, AtomicLong> gaugeValues = new ConcurrentHashMap<>();
@Bean(name = "analyticsConsumer")
public Consumer<Message<?>> analyticsConsumer(AnalyticsConsumerProperties properties,
MeterRegistry[] meterRegistries,
@Lazy @Qualifier(SpelExpressionConverterConfiguration.INTEGRATION_EVALUATION_CONTEXT) EvaluationContext context) {
@Bean
public Consumer<Message<?>> analyticsConsumer(AnalyticsConsumerProperties properties, MeterRegistry meterRegistry) {
// If the CompositeMeterRegistry is present the it already contains all
// non-composite registries.
// In this case we override the input meterRegistries to use the
// CompositeMeterRegistry only.
final MeterRegistry[] finalMeterRegistries = Stream.of(meterRegistries)
.filter(CompositeMeterRegistry.class::isInstance)
.findFirst()
.map(meterRegistry -> new MeterRegistry[] { meterRegistry })
.orElse(meterRegistries);
return message -> {
CharSequence meterNameRaw = properties.getComputedNameExpression()
.getValue(context, message, CharSequence.class);
String meterName = StringUtils.isEmpty(meterNameRaw) ? "empty" : meterNameRaw.toString();
return (message) -> {
CharSequence meterNameRaw = properties.getComputedNameExpression().getValue(message, CharSequence.class);
String meterName = StringUtils.hasText(meterNameRaw) ? meterNameRaw.toString() : "empty";
// All fixed tags together are passed with every meter update.
Tags fixedTags = this.toTags(properties.getTag().getFixed());
double amount = properties.getComputedAmountExpression().getValue(context, message, double.class);
Double amount = properties.getComputedAmountExpression().getValue(message, Double.class);
Map<String, List<Tag>> allGroupedTags = new HashMap<>();
// Tag Expressions
@@ -97,16 +80,15 @@ public class AnalyticsConsumerConfiguration {
.stream()
// maps a <name, expr> pair into [<name, expr#val_1>, ... <name,
// expr#val_N>] Tag array.
.map(namedExpression -> toList(namedExpression.getValue().getValue(context, message)).stream()
.map(tagValue -> Tag.of(namedExpression.getKey(), tagValue))
.map((namedExpression) -> toList(namedExpression.getValue().getValue(message)).stream()
.map((tagValue) -> Tag.of(namedExpression.getKey(), tagValue))
.collect(Collectors.toList()))
.flatMap(List::stream)
.collect(Collectors.groupingBy(Tag::getKey, Collectors.toList()));
allGroupedTags.putAll(groupedTags);
}
this.recordMetrics(finalMeterRegistries, meterName, fixedTags, allGroupedTags, amount,
properties.getMeterType());
recordMetrics(meterRegistry, meterName, fixedTags, allGroupedTags, amount, properties.getMeterType());
};
}
@@ -114,23 +96,23 @@ public class AnalyticsConsumerConfiguration {
* Converts a key/value Map into Tag(key,value) list. Filters out the empty key/value
* pairs.
* @param keyValueMap key/value map to convert into tags.
* @return Returns Tags list representing every non-empty key/value pair.
* @return tags list representing every non-empty key/value pair.
*/
protected Tags toTags(Map<String, String> keyValueMap) {
return CollectionUtils.isEmpty(keyValueMap) ? Tags.empty()
: Tags.of(keyValueMap.entrySet()
.stream()
.filter(e -> StringUtils.hasText(e.getKey()) && StringUtils.hasText(e.getValue()))
.map(e -> Tag.of(e.getKey(), e.getValue()))
.collect(Collectors.toList()));
.filter((e) -> StringUtils.hasText(e.getKey()) && StringUtils.hasText(e.getValue()))
.map((e) -> Tag.of(e.getKey(), e.getValue()))
.toList());
}
/**
* Converts the input value into an list of values. If the value is not a
* Convert the input value into a list of values. If the value is not a
* collection/array type the result is a single element list. For collection/array
* input value the result is the list of stringified content of this collection.
* @param value input value can be array, collection or single value.
* @return Returns value list.
* input value the result is the list of "stringified" content of this collection.
* @param value input value can be an array, collection or single value.
* @return the value list.
*/
protected List<String> toList(Object value) {
if (value == null) {
@@ -148,7 +130,7 @@ public class AnalyticsConsumerConfiguration {
.filter(Objects::nonNull)
.map(Object::toString)
.filter(StringUtils::hasText)
.collect(Collectors.toList());
.toList();
return CollectionUtils.isEmpty(list) ? Collections.singletonList(UNAVAILABLE_TAG) : list;
}
else {
@@ -156,10 +138,11 @@ public class AnalyticsConsumerConfiguration {
}
}
private void recordMetrics(MeterRegistry[] meterRegistries, String meterName, Tags fixedTags,
Map<String, List<Tag>> groupedTags, double amount, AnalyticsConsumerProperties.MeterType meterType) {
private void recordMetrics(MeterRegistry meterRegistry, String meterName, Tags fixedTags,
Map<String, List<Tag>> groupedTags, Double amount, AnalyticsConsumerProperties.MeterType meterType) {
if (!CollectionUtils.isEmpty(groupedTags)) {
groupedTags.values().stream().map(List::size).max(Integer::compareTo).ifPresent(max -> {
groupedTags.values().stream().map(List::size).max(Integer::compareTo).ifPresent((max) -> {
for (int i = 0; i < max; i++) {
Tags currentTags = Tags.of(fixedTags);
for (Map.Entry<String, List<Tag>> e : groupedTags.entrySet()) {
@@ -168,49 +151,44 @@ public class AnalyticsConsumerConfiguration {
}
// Update the meterName for every configured MaterRegistry.
record(meterRegistries, meterName, currentTags, amount, meterType);
record(meterRegistry, meterName, currentTags, amount, meterType);
}
});
}
else {
// Update the meterName for every configured MaterRegistry.
record(meterRegistries, meterName, fixedTags, amount, meterType);
record(meterRegistry, meterName, fixedTags, amount, meterType);
}
}
private void record(MeterRegistry[] meterRegistries, String meterName, Iterable<Tag> tags, double meterAmount,
private void record(MeterRegistry meterRegistry, String meterName, Iterable<Tag> tags, double meterAmount,
AnalyticsConsumerProperties.MeterType meterType) {
for (MeterRegistry meterRegistry : meterRegistries) {
if (meterType == AnalyticsConsumerProperties.MeterType.gauge) {
Meter.Id gaugeId = new Meter.Id(meterName, Tags.of(tags), null, null, Meter.Type.GAUGE);
if (!this.gaugeValues.containsKey(gaugeId)) {
this.gaugeValues.put(gaugeId, new AtomicLong((long) meterAmount));
}
else {
this.gaugeValues.get(gaugeId).set((long) meterAmount);
}
if (!isMeterRegistryContainsGauge(meterRegistry, gaugeId)) {
meterRegistry.gauge(meterName, tags, this.gaugeValues.get(gaugeId), AtomicLong::doubleValue);
}
}
else if (meterType == AnalyticsConsumerProperties.MeterType.counter) {
meterRegistry.counter(meterName, tags).increment(meterAmount);
if (meterType == AnalyticsConsumerProperties.MeterType.gauge) {
Meter.Id gaugeId = new Meter.Id(meterName, Tags.of(tags), null, null, Meter.Type.GAUGE);
if (!this.gaugeValues.containsKey(gaugeId)) {
this.gaugeValues.put(gaugeId, new AtomicLong((long) meterAmount));
}
else {
throw new RuntimeException("Unknown meter type:" + meterType);
this.gaugeValues.get(gaugeId).set((long) meterAmount);
}
if (!isMeterRegistryContainsGauge(meterRegistry, gaugeId)) {
meterRegistry.gauge(meterName, tags, this.gaugeValues.get(gaugeId), AtomicLong::doubleValue);
}
}
else if (meterType == AnalyticsConsumerProperties.MeterType.counter) {
meterRegistry.counter(meterName, tags).increment(meterAmount);
}
else {
throw new RuntimeException("Unknown meter type: " + meterType);
}
}
private boolean isMeterRegistryContainsGauge(MeterRegistry meterRegistry, Meter.Id gaugeId) {
return meterRegistry.find(gaugeId.getName()).gauges().stream().anyMatch(gauge -> gauge.getId().equals(gaugeId));
}
@Bean
@ConditionalOnMissingBean
public SimpleMeterRegistry simpleMeterRegistry() {
return new SimpleMeterRegistry();
private static boolean isMeterRegistryContainsGauge(MeterRegistry meterRegistry, Meter.Id gaugeId) {
return meterRegistry.find(gaugeId.getName())
.gauges()
.stream()
.anyMatch((gauge) -> gauge.getId().equals(gaugeId));
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,10 +24,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.expression.Expression;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.integration.expression.ValueExpression;
import org.springframework.validation.annotation.Validated;
/**
* The properties for analytics consumer.
*
* @author Christian Tzolov
* @author Artem Bilan
*/
@ConfigurationProperties("analytics")
@Validated
@@ -86,11 +90,11 @@ public class AnalyticsConsumerProperties {
private final MetricsTag tag = new MetricsTag();
public MetricsTag getTag() {
return tag;
return this.tag;
}
public MeterType getMeterType() {
return meterType;
return this.meterType;
}
public void setMeterType(MeterType meterType) {
@@ -98,10 +102,10 @@ public class AnalyticsConsumerProperties {
}
public String getName() {
if (name == null && nameExpression == null) {
return defaultName;
if (this.name == null && this.nameExpression == null) {
return this.defaultName;
}
return name;
return this.name;
}
public void setName(String name) {
@@ -109,7 +113,7 @@ public class AnalyticsConsumerProperties {
}
public Expression getNameExpression() {
return nameExpression;
return this.nameExpression;
}
public void setNameExpression(Expression nameExpression) {
@@ -117,7 +121,7 @@ public class AnalyticsConsumerProperties {
}
public Expression getAmountExpression() {
return amountExpression;
return this.amountExpression;
}
public void setAmountExpression(Expression amountExpression) {
@@ -125,11 +129,11 @@ public class AnalyticsConsumerProperties {
}
public Expression getComputedAmountExpression() {
return (amountExpression != null ? amountExpression : new LiteralExpression("1.0"));
return (this.amountExpression != null) ? this.amountExpression : new ValueExpression<>(1.0);
}
public Expression getComputedNameExpression() {
return (nameExpression != null ? nameExpression : new LiteralExpression(getName()));
return (this.nameExpression != null) ? this.nameExpression : new LiteralExpression(getName());
}
@AssertTrue(message = "exactly one of 'name' and 'nameExpression' must be set")
@@ -139,19 +143,17 @@ public class AnalyticsConsumerProperties {
@Override
public String toString() {
return "AnalyticsFunctionProperties{" + "defaultName='" + defaultName + '\'' + ", name=" + name + ", tag=" + tag
+ '}';
return "AnalyticsFunctionProperties{" + "defaultName='" + this.defaultName + '\'' + ", name=" + this.name
+ ", tag=" + this.tag + '}';
}
public static class MetricsTag {
/**
* DEPRECATED: Please use the analytics.tag.expression with literal SpEL
* expression.
*
* Custom, fixed Tags. Those tags have constant values, created once and then sent
* along with every published metrics. The convention to define a fixed Tags is:
* <code>
* expression. Custom, fixed Tags. Those tags have constant values, created once
* and then sent along with every published metrics. The convention to define a
* fixed Tags is: <code>
* analytics.tag.fixed.[tag-name]=[tag-value]
* </code>
*/
@@ -167,7 +169,7 @@ public class AnalyticsConsumerProperties {
private Map<String, Expression> expression;
public Map<String, String> getFixed() {
return fixed;
return this.fixed;
}
public void setFixed(Map<String, String> fixed) {
@@ -175,7 +177,7 @@ public class AnalyticsConsumerProperties {
}
public Map<String, Expression> getExpression() {
return expression;
return this.expression;
}
public void setExpression(Map<String, Expression> expression) {
@@ -184,7 +186,7 @@ public class AnalyticsConsumerProperties {
@Override
public String toString() {
return "MetricsTag{" + "fixed=" + fixed + ", expression=" + expression + '}';
return "MetricsTag{" + "fixed=" + this.fixed + ", expression=" + this.expression + '}';
}
}

View File

@@ -0,0 +1,4 @@
/**
* The analytics consumer classes.
*/
package org.springframework.cloud.fn.consumer.analytics;

View File

@@ -0,0 +1 @@
org.springframework.cloud.fn.consumer.analytics.AnalyticsConsumerConfiguration

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,9 +28,9 @@ import org.springframework.messaging.support.MessageBuilder;
import org.springframework.test.annotation.DirtiesContext;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = { "management.metrics.export.wavefront.enabled=false" })
properties = { "management.wavefront.metrics.export.enabled=false" })
@DirtiesContext
public class AnalyticsConsumerParentTest {
public abstract class AnalyticsConsumerParentTests {
@Autowired
protected SimpleMeterRegistry meterRegistry;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,7 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
@TestPropertySource(properties = { "analytics.name=counter666", "analytics.tag.expression.foo='bar'",
"analytics.amount-expression=payload.length()" })
class CountWithAmountTest extends AnalyticsConsumerParentTest {
class CountWithAmountTests extends AnalyticsConsumerParentTests {
@Test
void testCounterSink() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,7 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@TestPropertySource(properties = { "analytics.name=counter666", "analytics.tag.fixed.foo=",
"analytics.tag.expression.tag666=#jsonPath(payload,'$..noField')",
"analytics.tag.expression.test=#jsonPath(payload,'$..test')" })
class EmptyTagsTests extends AnalyticsConsumerParentTest {
class EmptyTagsTests extends AnalyticsConsumerParentTests {
@Test
void testCounterSink() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -29,11 +29,11 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author Christian Tzolov
*/
@TestPropertySource(properties = { "analytics.name-expression=payload" })
public class ExpressionCounterNameTests extends AnalyticsConsumerParentTest {
public class ExpressionCounterNameTests extends AnalyticsConsumerParentTests {
@Test
void testCounterSink() {
IntStream.range(0, 13).forEach(i -> analyticsConsumer.accept(new GenericMessage<>("hello")));
IntStream.range(0, 13).forEach((i) -> analyticsConsumer.accept(new GenericMessage<>("hello")));
assertThat(meterRegistry.find("hello").counter().count()).isEqualTo(13.0);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package org.springframework.cloud.fn.consumer.analytics;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;
import io.micrometer.core.instrument.Measurement;
import io.micrometer.core.instrument.Meter;
import org.junit.jupiter.api.Test;
@@ -32,15 +33,15 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
@TestPropertySource(
properties = { "analytics.name=counter666", "analytics.tag.fixed.foo=bar", "analytics.tag.fixed.gork=bork" })
public class FixedTagsTests extends AnalyticsConsumerParentTest {
public class FixedTagsTests extends AnalyticsConsumerParentTests {
@Test
void testAnalyticsSink() {
IntStream.range(0, 13).forEach(i -> analyticsConsumer.accept(new GenericMessage<>("hello")));
IntStream.range(0, 13).forEach((i) -> analyticsConsumer.accept(new GenericMessage<>("hello")));
Meter counterMeter = meterRegistry.find("counter666").meter();
assertThat(
StreamSupport.stream(counterMeter.measure().spliterator(), false).mapToDouble(m -> m.getValue()).sum())
.isEqualTo(13.0);
assertThat(StreamSupport.stream(counterMeter.measure().spliterator(), false)
.mapToDouble(Measurement::getValue)
.sum()).isEqualTo(13.0);
assertThat(counterMeter.getId().getTags().size()).isEqualTo(2);
assertThat(counterMeter.getId().getTag("foo")).isEqualTo("bar");

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,7 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
@TestPropertySource(properties = { "analytics.meter-type=gauge", "analytics.name=myGauge",
"analytics.tag.expression.foo='bar'", "analytics.amount-expression=payload.length()" })
class GaugeWithAmountTest extends AnalyticsConsumerParentTest {
class GaugeWithAmountTests extends AnalyticsConsumerParentTests {
@Test
void testAnalyticsSink() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,11 +31,11 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
@TestPropertySource(properties = { "analytics.name=counter666", "analytics.tag.expression.foo='bar'",
"analytics.tag.expression.gork='bork'" })
public class LiteralTagExpressionsTests extends AnalyticsConsumerParentTest {
public class LiteralTagExpressionsTests extends AnalyticsConsumerParentTests {
@Test
void testCounterSink() {
IntStream.range(0, 13).forEach(i -> analyticsConsumer.accept(new GenericMessage<>("hello")));
IntStream.range(0, 13).forEach((i) -> analyticsConsumer.accept(new GenericMessage<>("hello")));
Counter fooCounter = meterRegistry.find("counter666").tag("foo", "bar").counter();
assertThat(fooCounter.count()).isEqualTo(13.0);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -31,7 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@TestPropertySource(properties = { "analytics.name=counter666", "analytics.tag.fixed.foo=",
"analytics.tag.expression.tag666=#jsonPath(payload,'$..noField')",
"analytics.tag.expression.test=#jsonPath(payload,'$..test')" })
public class NullTagsTests extends AnalyticsConsumerParentTest {
public class NullTagsTests extends AnalyticsConsumerParentTests {
@Test
void testАnalyticsSink() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -36,7 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@TestPropertySource(properties = { "analytics.meter-type=counter", "analytics.name=stocks",
"analytics.tag.expression.symbol=#jsonPath(payload,'$.data.symbol')",
"analytics.tag.expression.exchange=#jsonPath(payload,'$.data.exchange')" })
public class StockExchangeAnalyticsTests extends AnalyticsConsumerParentTest {
public class StockExchangeAnalyticsTests extends AnalyticsConsumerParentTests {
@Test
public void testCounter() throws IOException {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -75,7 +75,7 @@ public class StockExchangeAnalyticsExample {
Supplier<String> stockMessageGenerator) {
// Run every second.
return args -> Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
return (args) -> Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
String message = stockMessageGenerator.get();
@@ -86,8 +86,8 @@ public class StockExchangeAnalyticsExample {
// Print current stock meters
System.out.println(meterRegistry.getMeters()
.stream()
.filter(meter -> meter.getId().getName().contains("stocks"))
.map(meter -> meter.getId().getType() + " | " + meter.getId() + " | " + meter.measure())
.filter((meter) -> meter.getId().getName().contains("stocks"))
.map((meter) -> meter.getId().getType() + " | " + meter.getId() + " | " + meter.measure())
.collect(Collectors.joining("\n"))
+ "\n=========================================================================");