committed by
GitHub
12 changed files with 595 additions and 68 deletions
@ -0,0 +1,92 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.model; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import lombok.ToString; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.server.common.data.device.data.lwm2m.ObjectAttributes; |
|||
|
|||
import java.util.HashSet; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
|
|||
import static org.thingsboard.common.util.CollectionsUtil.diffSets; |
|||
|
|||
@Data |
|||
@NoArgsConstructor |
|||
@ToString(exclude = "toCancelRead") |
|||
@Slf4j |
|||
public class LwM2MModelConfig { |
|||
private String endpoint; |
|||
private Map<String, ObjectAttributes> attributesToAdd; |
|||
private Set<String> attributesToRemove; |
|||
private Set<String> toObserve; |
|||
private Set<String> toCancelObserve; |
|||
private Set<String> toRead; |
|||
@JsonIgnore |
|||
private Set<String> toCancelRead; |
|||
|
|||
public LwM2MModelConfig(String endpoint) { |
|||
this.endpoint = endpoint; |
|||
this.attributesToAdd = new ConcurrentHashMap<>(); |
|||
this.attributesToRemove = ConcurrentHashMap.newKeySet(); |
|||
this.toObserve = ConcurrentHashMap.newKeySet(); |
|||
this.toCancelObserve = ConcurrentHashMap.newKeySet(); |
|||
this.toRead = ConcurrentHashMap.newKeySet(); |
|||
this.toCancelRead = new HashSet<>(); |
|||
} |
|||
|
|||
public void merge(LwM2MModelConfig modelConfig) { |
|||
if (modelConfig.isEmpty() && modelConfig.getToCancelRead().isEmpty()) { |
|||
return; |
|||
} |
|||
|
|||
modelConfig.getAttributesToAdd().forEach((k, v) -> { |
|||
if (this.attributesToRemove.contains(k)) { |
|||
this.attributesToRemove.remove(k); |
|||
} else { |
|||
this.attributesToAdd.put(k, v); |
|||
} |
|||
}); |
|||
|
|||
modelConfig.getAttributesToRemove().forEach(k -> { |
|||
if (this.attributesToAdd.containsKey(k)) { |
|||
this.attributesToRemove.remove(k); |
|||
} else { |
|||
this.attributesToRemove.add(k); |
|||
} |
|||
}); |
|||
|
|||
this.toObserve.addAll(diffSets(this.toCancelObserve, modelConfig.getToObserve())); |
|||
this.toCancelObserve.addAll(diffSets(this.toObserve, modelConfig.getToCancelObserve())); |
|||
|
|||
this.toObserve.removeAll(modelConfig.getToCancelObserve()); |
|||
this.toCancelObserve.removeAll(modelConfig.getToObserve()); |
|||
|
|||
this.toRead.removeAll(modelConfig.getToObserve()); |
|||
this.toRead.removeAll(modelConfig.getToCancelRead()); |
|||
this.toRead.addAll(modelConfig.getToRead()); |
|||
} |
|||
|
|||
@JsonIgnore |
|||
public boolean isEmpty() { |
|||
return attributesToAdd.isEmpty() && toObserve.isEmpty() && toCancelObserve.isEmpty() && toRead.isEmpty(); |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.model; |
|||
|
|||
import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; |
|||
|
|||
public interface LwM2MModelConfigService { |
|||
|
|||
void sendUpdates(LwM2mClient lwM2mClient); |
|||
|
|||
void sendUpdates(LwM2mClient lwM2mClient, LwM2MModelConfig modelConfig); |
|||
|
|||
void persistUpdates(String endpoint); |
|||
|
|||
void removeUpdates(String endpoint); |
|||
} |
|||
@ -0,0 +1,235 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.model; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.annotation.Lazy; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.common.data.device.data.lwm2m.ObjectAttributes; |
|||
import org.thingsboard.server.queue.util.AfterStartUp; |
|||
import org.thingsboard.server.queue.util.TbLwM2mTransportComponent; |
|||
import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; |
|||
import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.DownlinkRequestCallback; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.LwM2mDownlinkMsgHandler; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MCancelObserveCallback; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MCancelObserveRequest; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MObserveCallback; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MObserveRequest; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MReadCallback; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MReadRequest; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteAttributesCallback; |
|||
import org.thingsboard.server.transport.lwm2m.server.downlink.TbLwM2MWriteAttributesRequest; |
|||
import org.thingsboard.server.transport.lwm2m.server.log.LwM2MTelemetryLogService; |
|||
import org.thingsboard.server.transport.lwm2m.server.store.TbLwM2MModelConfigStore; |
|||
import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; |
|||
|
|||
import javax.annotation.PreDestroy; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ConcurrentMap; |
|||
import java.util.concurrent.TimeoutException; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Slf4j |
|||
@Service |
|||
@TbLwM2mTransportComponent |
|||
public class LwM2MModelConfigServiceImpl implements LwM2MModelConfigService { |
|||
|
|||
@Autowired |
|||
private TbLwM2MModelConfigStore modelStore; |
|||
|
|||
@Autowired |
|||
@Lazy |
|||
private LwM2mDownlinkMsgHandler downlinkMsgHandler; |
|||
@Autowired |
|||
@Lazy |
|||
private LwM2mUplinkMsgHandler uplinkMsgHandler; |
|||
@Autowired |
|||
@Lazy |
|||
private LwM2mClientContext clientContext; |
|||
|
|||
@Autowired |
|||
private LwM2MTelemetryLogService logService; |
|||
|
|||
private ConcurrentMap<String, LwM2MModelConfig> currentModelConfigs; |
|||
|
|||
@AfterStartUp(order = Integer.MAX_VALUE - 1) |
|||
private void init() { |
|||
List<LwM2MModelConfig> models = modelStore.getAll(); |
|||
log.debug("Fetched model configs: {}", models); |
|||
currentModelConfigs = models.stream() |
|||
.collect(Collectors.toConcurrentMap(LwM2MModelConfig::getEndpoint, m -> m)); |
|||
} |
|||
|
|||
@Override |
|||
public void sendUpdates(LwM2mClient lwM2mClient) { |
|||
LwM2MModelConfig modelConfig = currentModelConfigs.get(lwM2mClient.getEndpoint()); |
|||
if (modelConfig == null || modelConfig.isEmpty()) { |
|||
return; |
|||
} |
|||
|
|||
doSend(lwM2mClient, modelConfig); |
|||
} |
|||
|
|||
public void sendUpdates(LwM2mClient lwM2mClient, LwM2MModelConfig newModelConfig) { |
|||
String endpoint = lwM2mClient.getEndpoint(); |
|||
LwM2MModelConfig modelConfig = currentModelConfigs.get(endpoint); |
|||
if (modelConfig == null || modelConfig.isEmpty()) { |
|||
modelConfig = newModelConfig; |
|||
currentModelConfigs.put(endpoint, modelConfig); |
|||
} else { |
|||
modelConfig.merge(newModelConfig); |
|||
} |
|||
|
|||
if (lwM2mClient.isAsleep()) { |
|||
modelStore.put(modelConfig); |
|||
} else { |
|||
doSend(lwM2mClient, modelConfig); |
|||
} |
|||
} |
|||
|
|||
private void doSend(LwM2mClient lwM2mClient, LwM2MModelConfig modelConfig) { |
|||
log.trace("Send LwM2M Model updates: [{}]", modelConfig); |
|||
|
|||
String endpoint = lwM2mClient.getEndpoint(); |
|||
|
|||
Map<String, ObjectAttributes> attrToAdd = modelConfig.getAttributesToAdd(); |
|||
attrToAdd.forEach((id, attributes) -> { |
|||
TbLwM2MWriteAttributesRequest request = TbLwM2MWriteAttributesRequest.builder().versionedId(id) |
|||
.attributes(attributes) |
|||
.timeout(clientContext.getRequestTimeout(lwM2mClient)).build(); |
|||
downlinkMsgHandler.sendWriteAttributesRequest(lwM2mClient, request, |
|||
createDownlinkProxyCallback(() -> { |
|||
attrToAdd.remove(id); |
|||
if (modelConfig.isEmpty()) { |
|||
modelStore.remove(endpoint); |
|||
} |
|||
}, new TbLwM2MWriteAttributesCallback(logService, lwM2mClient, id)) |
|||
); |
|||
}); |
|||
|
|||
Set<String> attrToRemove = modelConfig.getAttributesToRemove(); |
|||
attrToRemove.forEach((id) -> { |
|||
TbLwM2MWriteAttributesRequest request = TbLwM2MWriteAttributesRequest.builder().versionedId(id) |
|||
.attributes(new ObjectAttributes()) |
|||
.timeout(clientContext.getRequestTimeout(lwM2mClient)).build(); |
|||
downlinkMsgHandler.sendWriteAttributesRequest(lwM2mClient, request, |
|||
createDownlinkProxyCallback(() -> { |
|||
attrToRemove.remove(id); |
|||
if (modelConfig.isEmpty()) { |
|||
modelStore.remove(endpoint); |
|||
} |
|||
}, new TbLwM2MWriteAttributesCallback(logService, lwM2mClient, id)) |
|||
); |
|||
}); |
|||
|
|||
Set<String> toRead = modelConfig.getToRead(); |
|||
toRead.forEach(id -> { |
|||
TbLwM2MReadRequest request = TbLwM2MReadRequest.builder().versionedId(id) |
|||
.timeout(clientContext.getRequestTimeout(lwM2mClient)).build(); |
|||
downlinkMsgHandler.sendReadRequest(lwM2mClient, request, |
|||
createDownlinkProxyCallback(() -> { |
|||
toRead.remove(id); |
|||
if (modelConfig.isEmpty()) { |
|||
modelStore.remove(endpoint); |
|||
} |
|||
}, new TbLwM2MReadCallback(uplinkMsgHandler, logService, lwM2mClient, id)) |
|||
); |
|||
}); |
|||
|
|||
Set<String> toObserve = modelConfig.getToObserve(); |
|||
toObserve.forEach(id -> { |
|||
TbLwM2MObserveRequest request = TbLwM2MObserveRequest.builder().versionedId(id) |
|||
.timeout(clientContext.getRequestTimeout(lwM2mClient)).build(); |
|||
downlinkMsgHandler.sendObserveRequest(lwM2mClient, request, |
|||
createDownlinkProxyCallback(() -> { |
|||
toObserve.remove(id); |
|||
if (modelConfig.isEmpty()) { |
|||
modelStore.remove(endpoint); |
|||
} |
|||
}, new TbLwM2MObserveCallback(uplinkMsgHandler, logService, lwM2mClient, id)) |
|||
); |
|||
}); |
|||
|
|||
Set<String> toCancelObserve = modelConfig.getToCancelObserve(); |
|||
toCancelObserve.forEach(id -> { |
|||
TbLwM2MCancelObserveRequest request = TbLwM2MCancelObserveRequest.builder().versionedId(id) |
|||
.timeout(clientContext.getRequestTimeout(lwM2mClient)).build(); |
|||
downlinkMsgHandler.sendCancelObserveRequest(lwM2mClient, request, |
|||
createDownlinkProxyCallback(() -> { |
|||
toCancelObserve.remove(id); |
|||
if (modelConfig.isEmpty()) { |
|||
modelStore.remove(endpoint); |
|||
} |
|||
}, new TbLwM2MCancelObserveCallback(logService, lwM2mClient, id)) |
|||
); |
|||
}); |
|||
} |
|||
|
|||
private <R, T> DownlinkRequestCallback<R, T> createDownlinkProxyCallback(Runnable processRemove, DownlinkRequestCallback<R, T> callback) { |
|||
return new DownlinkRequestCallback<>() { |
|||
@Override |
|||
public void onSuccess(R request, T response) { |
|||
processRemove.run(); |
|||
callback.onSuccess(request, response); |
|||
} |
|||
|
|||
@Override |
|||
public void onValidationError(String params, String msg) { |
|||
processRemove.run(); |
|||
callback.onValidationError(params, msg); |
|||
} |
|||
|
|||
@Override |
|||
public void onError(String params, Exception e) { |
|||
try { |
|||
if (e instanceof TimeoutException) { |
|||
return; |
|||
} |
|||
processRemove.run(); |
|||
} finally { |
|||
callback.onError(params, e); |
|||
} |
|||
} |
|||
|
|||
}; |
|||
} |
|||
|
|||
@Override |
|||
public void persistUpdates(String endpoint) { |
|||
LwM2MModelConfig modelConfig = currentModelConfigs.get(endpoint); |
|||
if (modelConfig != null && !modelConfig.isEmpty()) { |
|||
modelStore.put(modelConfig); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void removeUpdates(String endpoint) { |
|||
currentModelConfigs.remove(endpoint); |
|||
} |
|||
|
|||
@PreDestroy |
|||
private void destroy() { |
|||
currentModelConfigs.values().forEach(model -> { |
|||
if (model != null && !model.isEmpty()) { |
|||
modelStore.put(model); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.store; |
|||
|
|||
import org.thingsboard.server.transport.lwm2m.server.model.LwM2MModelConfig; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
|
|||
public class TbDummyLwM2MModelConfigStore implements TbLwM2MModelConfigStore { |
|||
@Override |
|||
public List<LwM2MModelConfig> getAll() { |
|||
return Collections.emptyList(); |
|||
} |
|||
|
|||
@Override |
|||
public void put(LwM2MModelConfig modelConfig) { |
|||
|
|||
} |
|||
|
|||
@Override |
|||
public void remove(String endpoint) { |
|||
|
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.store; |
|||
|
|||
import org.thingsboard.server.transport.lwm2m.server.model.LwM2MModelConfig; |
|||
|
|||
import java.util.List; |
|||
|
|||
public interface TbLwM2MModelConfigStore { |
|||
List<LwM2MModelConfig> getAll(); |
|||
|
|||
void put(LwM2MModelConfig modelConfig); |
|||
|
|||
void remove(String endpoint); |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.transport.lwm2m.server.store; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.data.redis.connection.RedisClusterConnection; |
|||
import org.springframework.data.redis.connection.RedisConnectionFactory; |
|||
import org.springframework.data.redis.core.Cursor; |
|||
import org.springframework.data.redis.core.ScanOptions; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.transport.lwm2m.server.model.LwM2MModelConfig; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
|
|||
@Slf4j |
|||
@AllArgsConstructor |
|||
public class TbRedisLwM2MModelConfigStore implements TbLwM2MModelConfigStore { |
|||
private static final String MODEL_EP = "MODEL#EP#"; |
|||
private final RedisConnectionFactory connectionFactory; |
|||
|
|||
@Override |
|||
public List<LwM2MModelConfig> getAll() { |
|||
try (var connection = connectionFactory.getConnection()) { |
|||
List<LwM2MModelConfig> configs = new ArrayList<>(); |
|||
ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(MODEL_EP + "*").build(); |
|||
List<Cursor<byte[]>> scans = new ArrayList<>(); |
|||
if (connection instanceof RedisClusterConnection) { |
|||
((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { |
|||
scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); |
|||
}); |
|||
} else { |
|||
scans.add(connection.scan(scanOptions)); |
|||
} |
|||
|
|||
scans.forEach(scan -> { |
|||
scan.forEachRemaining(key -> { |
|||
byte[] element = connection.get(key); |
|||
configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class)); |
|||
}); |
|||
}); |
|||
return configs; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void put(LwM2MModelConfig modelConfig) { |
|||
byte[] clientSerialized = JacksonUtil.writeValueAsBytes(modelConfig); |
|||
try (var connection = connectionFactory.getConnection()) { |
|||
connection.getSet(getKey(modelConfig.getEndpoint()), clientSerialized); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void remove(String endpoint) { |
|||
try (var connection = connectionFactory.getConnection()) { |
|||
connection.del(getKey(endpoint)); |
|||
} |
|||
} |
|||
|
|||
private byte[] getKey(String endpoint) { |
|||
return (MODEL_EP + endpoint).getBytes(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
/** |
|||
* Copyright © 2016-2021 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.common.util; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.Set; |
|||
import java.util.stream.Collectors; |
|||
|
|||
public class CollectionsUtil { |
|||
public static boolean isEmpty(Collection<?> collection) { |
|||
return collection == null || collection.isEmpty(); |
|||
} |
|||
|
|||
public static boolean isNotEmpty(Collection<?> collection) { |
|||
return !isEmpty(collection); |
|||
} |
|||
|
|||
/** |
|||
* Returns new set with elements that are present in set B(new) but absent in set A(old). |
|||
*/ |
|||
public static <T> Set<T> diffSets(Set<T> a, Set<T> b) { |
|||
return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toSet()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue