001package ca.uhn.fhir.interceptor.executor; 002 003/*- 004 * #%L 005 * HAPI FHIR - Core Library 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.interceptor.api.HookParams; 024import ca.uhn.fhir.interceptor.api.IBaseInterceptorBroadcaster; 025import ca.uhn.fhir.interceptor.api.IBaseInterceptorService; 026import ca.uhn.fhir.interceptor.api.IPointcut; 027import ca.uhn.fhir.interceptor.api.Interceptor; 028import ca.uhn.fhir.interceptor.api.Pointcut; 029import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 030import ca.uhn.fhir.util.ReflectionUtil; 031import com.google.common.annotations.VisibleForTesting; 032import com.google.common.collect.ArrayListMultimap; 033import com.google.common.collect.ListMultimap; 034import com.google.common.collect.Multimaps; 035import org.apache.commons.lang3.Validate; 036import org.apache.commons.lang3.builder.ToStringBuilder; 037import org.apache.commons.lang3.builder.ToStringStyle; 038import org.apache.commons.lang3.reflect.MethodUtils; 039import org.slf4j.Logger; 040import org.slf4j.LoggerFactory; 041 042import javax.annotation.Nonnull; 043import javax.annotation.Nullable; 044import java.lang.annotation.Annotation; 045import java.lang.reflect.AnnotatedElement; 046import java.lang.reflect.InvocationTargetException; 047import java.lang.reflect.Method; 048import java.util.ArrayList; 049import java.util.Arrays; 050import java.util.Collection; 051import java.util.Collections; 052import java.util.Comparator; 053import java.util.HashMap; 054import java.util.IdentityHashMap; 055import java.util.List; 056import java.util.Map; 057import java.util.Optional; 058import java.util.concurrent.atomic.AtomicInteger; 059import java.util.function.Predicate; 060import java.util.stream.Collectors; 061 062public abstract class BaseInterceptorService<POINTCUT extends IPointcut> implements IBaseInterceptorService<POINTCUT>, IBaseInterceptorBroadcaster<POINTCUT> { 063 private static final Logger ourLog = LoggerFactory.getLogger(BaseInterceptorService.class); 064 private final List<Object> myInterceptors = new ArrayList<>(); 065 private final ListMultimap<POINTCUT, BaseInvoker> myGlobalInvokers = ArrayListMultimap.create(); 066 private final ListMultimap<POINTCUT, BaseInvoker> myAnonymousInvokers = ArrayListMultimap.create(); 067 private final Object myRegistryMutex = new Object(); 068 private final ThreadLocal<ListMultimap<POINTCUT, BaseInvoker>> myThreadlocalInvokers = new ThreadLocal<>(); 069 private String myName; 070 private boolean myThreadlocalInvokersEnabled = true; 071 private boolean myWarnOnInterceptorWithNoHooks = true; 072 073 /** 074 * Constructor which uses a default name of "default" 075 */ 076 public BaseInterceptorService() { 077 this("default"); 078 } 079 080 /** 081 * Constructor 082 * 083 * @param theName The name for this registry (useful for troubleshooting) 084 */ 085 public BaseInterceptorService(String theName) { 086 super(); 087 myName = theName; 088 } 089 090 /** 091 * Should a warning be issued if an interceptor is registered and it has no hooks 092 */ 093 public void setWarnOnInterceptorWithNoHooks(boolean theWarnOnInterceptorWithNoHooks) { 094 myWarnOnInterceptorWithNoHooks = theWarnOnInterceptorWithNoHooks; 095 } 096 097 /** 098 * Are threadlocal interceptors enabled on this registry (defaults to true) 099 */ 100 public boolean isThreadlocalInvokersEnabled() { 101 return myThreadlocalInvokersEnabled; 102 } 103 104 /** 105 * Are threadlocal interceptors enabled on this registry (defaults to true) 106 */ 107 public void setThreadlocalInvokersEnabled(boolean theThreadlocalInvokersEnabled) { 108 myThreadlocalInvokersEnabled = theThreadlocalInvokersEnabled; 109 } 110 111 @VisibleForTesting 112 List<Object> getGlobalInterceptorsForUnitTest() { 113 return myInterceptors; 114 } 115 116 public void setName(String theName) { 117 myName = theName; 118 } 119 120 protected void registerAnonymousInterceptor(POINTCUT thePointcut, Object theInterceptor, BaseInvoker theInvoker) { 121 Validate.notNull(thePointcut); 122 Validate.notNull(theInterceptor); 123 synchronized (myRegistryMutex) { 124 125 myAnonymousInvokers.put(thePointcut, theInvoker); 126 if (!isInterceptorAlreadyRegistered(theInterceptor)) { 127 myInterceptors.add(theInterceptor); 128 } 129 } 130 } 131 132 @Override 133 public List<Object> getAllRegisteredInterceptors() { 134 synchronized (myRegistryMutex) { 135 List<Object> retVal = new ArrayList<>(); 136 retVal.addAll(myInterceptors); 137 return Collections.unmodifiableList(retVal); 138 } 139 } 140 141 @Override 142 @VisibleForTesting 143 public void unregisterAllInterceptors() { 144 synchronized (myRegistryMutex) { 145 unregisterInterceptors(myAnonymousInvokers.values()); 146 unregisterInterceptors(myGlobalInvokers.values()); 147 unregisterInterceptors(myInterceptors); 148 } 149 } 150 151 @Override 152 public void unregisterInterceptors(@Nullable Collection<?> theInterceptors) { 153 if (theInterceptors != null) { 154 new ArrayList<>(theInterceptors).forEach(t -> unregisterInterceptor(t)); 155 } 156 } 157 158 @Override 159 public void registerInterceptors(@Nullable Collection<?> theInterceptors) { 160 if (theInterceptors != null) { 161 theInterceptors.forEach(t -> registerInterceptor(t)); 162 } 163 } 164 165 @Override 166 public void unregisterInterceptorsIf(Predicate<Object> theShouldUnregisterFunction) { 167 unregisterInterceptorsIf(theShouldUnregisterFunction, myGlobalInvokers); 168 unregisterInterceptorsIf(theShouldUnregisterFunction, myAnonymousInvokers); 169 } 170 171 private void unregisterInterceptorsIf(Predicate<Object> theShouldUnregisterFunction, ListMultimap<POINTCUT, BaseInvoker> theGlobalInvokers) { 172 theGlobalInvokers.entries().removeIf(t -> theShouldUnregisterFunction.test(t.getValue().getInterceptor())); 173 } 174 175 @Override 176 public boolean registerThreadLocalInterceptor(Object theInterceptor) { 177 if (!myThreadlocalInvokersEnabled) { 178 return false; 179 } 180 ListMultimap<POINTCUT, BaseInvoker> invokers = getThreadLocalInvokerMultimap(); 181 scanInterceptorAndAddToInvokerMultimap(theInterceptor, invokers); 182 return !invokers.isEmpty(); 183 184 } 185 186 @Override 187 public void unregisterThreadLocalInterceptor(Object theInterceptor) { 188 if (myThreadlocalInvokersEnabled) { 189 ListMultimap<POINTCUT, BaseInvoker> invokers = getThreadLocalInvokerMultimap(); 190 invokers.entries().removeIf(t -> t.getValue().getInterceptor() == theInterceptor); 191 if (invokers.isEmpty()) { 192 myThreadlocalInvokers.remove(); 193 } 194 } 195 } 196 197 private ListMultimap<POINTCUT, BaseInvoker> getThreadLocalInvokerMultimap() { 198 ListMultimap<POINTCUT, BaseInvoker> invokers = myThreadlocalInvokers.get(); 199 if (invokers == null) { 200 invokers = Multimaps.synchronizedListMultimap(ArrayListMultimap.create()); 201 myThreadlocalInvokers.set(invokers); 202 } 203 return invokers; 204 } 205 206 @Override 207 public boolean registerInterceptor(Object theInterceptor) { 208 synchronized (myRegistryMutex) { 209 210 if (isInterceptorAlreadyRegistered(theInterceptor)) { 211 return false; 212 } 213 214 List<HookInvoker> addedInvokers = scanInterceptorAndAddToInvokerMultimap(theInterceptor, myGlobalInvokers); 215 if (addedInvokers.isEmpty()) { 216 if (myWarnOnInterceptorWithNoHooks) { 217 ourLog.warn("Interceptor registered with no valid hooks - Type was: {}", theInterceptor.getClass().getName()); 218 } 219 return false; 220 } 221 222 // Add to the global list 223 myInterceptors.add(theInterceptor); 224 sortByOrderAnnotation(myInterceptors); 225 226 return true; 227 } 228 } 229 230 private boolean isInterceptorAlreadyRegistered(Object theInterceptor) { 231 for (Object next : myInterceptors) { 232 if (next == theInterceptor) { 233 return true; 234 } 235 } 236 return false; 237 } 238 239 @Override 240 public boolean unregisterInterceptor(Object theInterceptor) { 241 synchronized (myRegistryMutex) { 242 boolean removed = myInterceptors.removeIf(t -> t == theInterceptor); 243 removed |= myGlobalInvokers.entries().removeIf(t -> t.getValue().getInterceptor() == theInterceptor); 244 removed |= myAnonymousInvokers.entries().removeIf(t -> t.getValue().getInterceptor() == theInterceptor); 245 return removed; 246 } 247 } 248 249 private void sortByOrderAnnotation(List<Object> theObjects) { 250 IdentityHashMap<Object, Integer> interceptorToOrder = new IdentityHashMap<>(); 251 for (Object next : theObjects) { 252 Interceptor orderAnnotation = next.getClass().getAnnotation(Interceptor.class); 253 int order = orderAnnotation != null ? orderAnnotation.order() : 0; 254 interceptorToOrder.put(next, order); 255 } 256 257 theObjects.sort((a, b) -> { 258 Integer orderA = interceptorToOrder.get(a); 259 Integer orderB = interceptorToOrder.get(b); 260 return orderA - orderB; 261 }); 262 } 263 264 @Override 265 public Object callHooksAndReturnObject(POINTCUT thePointcut, HookParams theParams) { 266 assert haveAppropriateParams(thePointcut, theParams); 267 assert thePointcut.getReturnType() != void.class; 268 269 return doCallHooks(thePointcut, theParams, null); 270 } 271 272 @Override 273 public boolean hasHooks(POINTCUT thePointcut) { 274 return myGlobalInvokers.containsKey(thePointcut) 275 || myAnonymousInvokers.containsKey(thePointcut) 276 || hasThreadLocalHooks(thePointcut); 277 } 278 279 private boolean hasThreadLocalHooks(POINTCUT thePointcut) { 280 ListMultimap<POINTCUT, BaseInvoker> hooks = myThreadlocalInvokersEnabled ? myThreadlocalInvokers.get() : null; 281 return hooks != null && hooks.containsKey(thePointcut); 282 } 283 284 @Override 285 public boolean callHooks(POINTCUT thePointcut, HookParams theParams) { 286 assert haveAppropriateParams(thePointcut, theParams); 287 assert thePointcut.getReturnType() == void.class || thePointcut.getReturnType() == boolean.class; 288 289 Object retValObj = doCallHooks(thePointcut, theParams, true); 290 return (Boolean) retValObj; 291 } 292 293 private Object doCallHooks(POINTCUT thePointcut, HookParams theParams, Object theRetVal) { 294 List<BaseInvoker> invokers = getInvokersForPointcut(thePointcut); 295 296 /* 297 * Call each hook in order 298 */ 299 for (BaseInvoker nextInvoker : invokers) { 300 Object nextOutcome = nextInvoker.invoke(theParams); 301 Class<?> pointcutReturnType = thePointcut.getReturnType(); 302 if (pointcutReturnType.equals(boolean.class)) { 303 Boolean nextOutcomeAsBoolean = (Boolean) nextOutcome; 304 if (Boolean.FALSE.equals(nextOutcomeAsBoolean)) { 305 ourLog.trace("callHooks({}) for invoker({}) returned false", thePointcut, nextInvoker); 306 theRetVal = false; 307 break; 308 } 309 } else if (pointcutReturnType.equals(void.class) == false) { 310 if (nextOutcome != null) { 311 theRetVal = nextOutcome; 312 break; 313 } 314 } 315 } 316 317 return theRetVal; 318 } 319 320 @VisibleForTesting 321 List<Object> getInterceptorsWithInvokersForPointcut(POINTCUT thePointcut) { 322 return getInvokersForPointcut(thePointcut) 323 .stream() 324 .map(BaseInvoker::getInterceptor) 325 .collect(Collectors.toList()); 326 } 327 328 /** 329 * Returns an ordered list of invokers for the given pointcut. Note that 330 * a new and stable list is returned to.. do whatever you want with it. 331 */ 332 private List<BaseInvoker> getInvokersForPointcut(POINTCUT thePointcut) { 333 List<BaseInvoker> invokers; 334 335 synchronized (myRegistryMutex) { 336 List<BaseInvoker> globalInvokers = myGlobalInvokers.get(thePointcut); 337 List<BaseInvoker> anonymousInvokers = myAnonymousInvokers.get(thePointcut); 338 List<BaseInvoker> threadLocalInvokers = null; 339 if (myThreadlocalInvokersEnabled) { 340 ListMultimap<POINTCUT, BaseInvoker> pointcutToInvokers = myThreadlocalInvokers.get(); 341 if (pointcutToInvokers != null) { 342 threadLocalInvokers = pointcutToInvokers.get(thePointcut); 343 } 344 } 345 invokers = union(globalInvokers, anonymousInvokers, threadLocalInvokers); 346 } 347 348 return invokers; 349 } 350 351 /** 352 * First argument must be the global invoker list!! 353 */ 354 @SafeVarargs 355 private final List<BaseInvoker> union(List<BaseInvoker>... theInvokersLists) { 356 List<BaseInvoker> haveOne = null; 357 boolean haveMultiple = false; 358 for (List<BaseInvoker> nextInvokerList : theInvokersLists) { 359 if (nextInvokerList == null || nextInvokerList.isEmpty()) { 360 continue; 361 } 362 363 if (haveOne == null) { 364 haveOne = nextInvokerList; 365 } else { 366 haveMultiple = true; 367 } 368 } 369 370 if (haveOne == null) { 371 return Collections.emptyList(); 372 } 373 374 List<BaseInvoker> retVal; 375 376 if (haveMultiple == false) { 377 378 // The global list doesn't need to be sorted every time since it's sorted on 379 // insertion each time. Doing so is a waste of cycles.. 380 if (haveOne == theInvokersLists[0]) { 381 retVal = haveOne; 382 } else { 383 retVal = new ArrayList<>(haveOne); 384 retVal.sort(Comparator.naturalOrder()); 385 } 386 387 } else { 388 389 retVal = Arrays 390 .stream(theInvokersLists) 391 .filter(t -> t != null) 392 .flatMap(t -> t.stream()) 393 .sorted() 394 .collect(Collectors.toList()); 395 396 } 397 398 return retVal; 399 } 400 401 /** 402 * Only call this when assertions are enabled, it's expensive 403 */ 404 boolean haveAppropriateParams(POINTCUT thePointcut, HookParams theParams) { 405 if (theParams.getParamsForType().values().size() != thePointcut.getParameterTypes().size()) { 406 throw new IllegalArgumentException(String.format("Wrong number of params for pointcut %s - Wanted %s but found %s", thePointcut.name(), toErrorString(thePointcut.getParameterTypes()), theParams.getParamsForType().values().stream().map(t -> t != null ? t.getClass().getSimpleName() : "null").sorted().collect(Collectors.toList()))); 407 } 408 409 List<String> wantedTypes = new ArrayList<>(thePointcut.getParameterTypes()); 410 411 ListMultimap<Class<?>, Object> givenTypes = theParams.getParamsForType(); 412 for (Class<?> nextTypeClass : givenTypes.keySet()) { 413 String nextTypeName = nextTypeClass.getName(); 414 for (Object nextParamValue : givenTypes.get(nextTypeClass)) { 415 Validate.isTrue(nextParamValue == null || nextTypeClass.isAssignableFrom(nextParamValue.getClass()), "Invalid params for pointcut %s - %s is not of type %s", thePointcut.name(), nextParamValue != null ? nextParamValue.getClass() : "null", nextTypeClass); 416 Validate.isTrue(wantedTypes.remove(nextTypeName), "Invalid params for pointcut %s - Wanted %s but found %s", thePointcut.name(), toErrorString(thePointcut.getParameterTypes()), nextTypeName); 417 } 418 } 419 420 return true; 421 } 422 423 private List<HookInvoker> scanInterceptorAndAddToInvokerMultimap(Object theInterceptor, ListMultimap<POINTCUT, BaseInvoker> theInvokers) { 424 Class<?> interceptorClass = theInterceptor.getClass(); 425 int typeOrder = determineOrder(interceptorClass); 426 427 List<HookInvoker> addedInvokers = scanInterceptorForHookMethods(theInterceptor, typeOrder); 428 429 // Invoke the REGISTERED pointcut for any added hooks 430 addedInvokers.stream() 431 .filter(t -> Pointcut.INTERCEPTOR_REGISTERED.equals(t.getPointcut())) 432 .forEach(t -> t.invoke(new HookParams())); 433 434 // Register the interceptor and its various hooks 435 for (HookInvoker nextAddedHook : addedInvokers) { 436 IPointcut nextPointcut = nextAddedHook.getPointcut(); 437 if (nextPointcut.equals(Pointcut.INTERCEPTOR_REGISTERED)) { 438 continue; 439 } 440 theInvokers.put((POINTCUT) nextPointcut, nextAddedHook); 441 } 442 443 // Make sure we're always sorted according to the order declared in 444 // @Order 445 for (IPointcut nextPointcut : theInvokers.keys()) { 446 List<BaseInvoker> nextInvokerList = theInvokers.get((POINTCUT) nextPointcut); 447 nextInvokerList.sort(Comparator.naturalOrder()); 448 } 449 450 return addedInvokers; 451 } 452 453 /** 454 * @return Returns a list of any added invokers 455 */ 456 private List<HookInvoker> scanInterceptorForHookMethods(Object theInterceptor, int theTypeOrder) { 457 ArrayList<HookInvoker> retVal = new ArrayList<>(); 458 for (Method nextMethod : ReflectionUtil.getDeclaredMethods(theInterceptor.getClass(), true)) { 459 Optional<HookDescriptor> hook = scanForHook(nextMethod); 460 461 if (hook.isPresent()) { 462 int methodOrder = theTypeOrder; 463 int methodOrderAnnotation = hook.get().getOrder(); 464 if (methodOrderAnnotation != Interceptor.DEFAULT_ORDER) { 465 methodOrder = methodOrderAnnotation; 466 } 467 468 retVal.add(new HookInvoker(hook.get(), theInterceptor, nextMethod, methodOrder)); 469 } 470 } 471 472 return retVal; 473 } 474 475 protected abstract Optional<HookDescriptor> scanForHook(Method nextMethod); 476 477 protected abstract static class BaseInvoker implements Comparable<BaseInvoker> { 478 479 private final int myOrder; 480 private final Object myInterceptor; 481 482 BaseInvoker(Object theInterceptor, int theOrder) { 483 myInterceptor = theInterceptor; 484 myOrder = theOrder; 485 } 486 487 public Object getInterceptor() { 488 return myInterceptor; 489 } 490 491 abstract Object invoke(HookParams theParams); 492 493 @Override 494 public int compareTo(BaseInvoker theInvoker) { 495 return myOrder - theInvoker.myOrder; 496 } 497 } 498 499 private static class HookInvoker extends BaseInvoker { 500 501 private final Method myMethod; 502 private final Class<?>[] myParameterTypes; 503 private final int[] myParameterIndexes; 504 private final IPointcut myPointcut; 505 506 /** 507 * Constructor 508 */ 509 private HookInvoker(HookDescriptor theHook, @Nonnull Object theInterceptor, @Nonnull Method theHookMethod, int theOrder) { 510 super(theInterceptor, theOrder); 511 myPointcut = theHook.getPointcut(); 512 myParameterTypes = theHookMethod.getParameterTypes(); 513 myMethod = theHookMethod; 514 515 Class<?> returnType = theHookMethod.getReturnType(); 516 if (myPointcut.getReturnType().equals(boolean.class)) { 517 Validate.isTrue(boolean.class.equals(returnType) || void.class.equals(returnType), "Method does not return boolean or void: %s", theHookMethod); 518 } else if (myPointcut.getReturnType().equals(void.class)) { 519 Validate.isTrue(void.class.equals(returnType), "Method does not return void: %s", theHookMethod); 520 } else { 521 Validate.isTrue(myPointcut.getReturnType().isAssignableFrom(returnType) || void.class.equals(returnType), "Method does not return %s or void: %s", myPointcut.getReturnType(), theHookMethod); 522 } 523 524 myParameterIndexes = new int[myParameterTypes.length]; 525 Map<Class<?>, AtomicInteger> typeToCount = new HashMap<>(); 526 for (int i = 0; i < myParameterTypes.length; i++) { 527 AtomicInteger counter = typeToCount.computeIfAbsent(myParameterTypes[i], t -> new AtomicInteger(0)); 528 myParameterIndexes[i] = counter.getAndIncrement(); 529 } 530 531 myMethod.setAccessible(true); 532 } 533 534 @Override 535 public String toString() { 536 return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) 537 .append("method", myMethod) 538 .toString(); 539 } 540 541 public IPointcut getPointcut() { 542 return myPointcut; 543 } 544 545 /** 546 * @return Returns true/false if the hook method returns a boolean, returns true otherwise 547 */ 548 @Override 549 Object invoke(HookParams theParams) { 550 551 Object[] args = new Object[myParameterTypes.length]; 552 for (int i = 0; i < myParameterTypes.length; i++) { 553 Class<?> nextParamType = myParameterTypes[i]; 554 if (nextParamType.equals(Pointcut.class)) { 555 args[i] = myPointcut; 556 } else { 557 int nextParamIndex = myParameterIndexes[i]; 558 Object nextParamValue = theParams.get(nextParamType, nextParamIndex); 559 args[i] = nextParamValue; 560 } 561 } 562 563 // Invoke the method 564 try { 565 return myMethod.invoke(getInterceptor(), args); 566 } catch (InvocationTargetException e) { 567 Throwable targetException = e.getTargetException(); 568 if (myPointcut.isShouldLogAndSwallowException(targetException)) { 569 ourLog.error("Exception thrown by interceptor: " + targetException.toString(), targetException); 570 return null; 571 } 572 573 if (targetException instanceof RuntimeException) { 574 throw ((RuntimeException) targetException); 575 } else { 576 throw new InternalErrorException("Failure invoking interceptor for pointcut(s) " + getPointcut(), targetException); 577 } 578 } catch (Exception e) { 579 throw new InternalErrorException(e); 580 } 581 582 } 583 584 } 585 586 protected static class HookDescriptor { 587 588 private final IPointcut myPointcut; 589 private final int myOrder; 590 591 public HookDescriptor(IPointcut thePointcut, int theOrder) { 592 myPointcut = thePointcut; 593 myOrder = theOrder; 594 } 595 596 IPointcut getPointcut() { 597 return myPointcut; 598 } 599 600 int getOrder() { 601 return myOrder; 602 } 603 604 } 605 606 protected static <T extends Annotation> Optional<T> findAnnotation(AnnotatedElement theObject, Class<T> theHookClass) { 607 T annotation; 608 if (theObject instanceof Method) { 609 annotation = MethodUtils.getAnnotation((Method) theObject, theHookClass, true, true); 610 } else { 611 annotation = theObject.getAnnotation(theHookClass); 612 } 613 return Optional.ofNullable(annotation); 614 } 615 616 private static int determineOrder(Class<?> theInterceptorClass) { 617 int typeOrder = Interceptor.DEFAULT_ORDER; 618 Optional<Interceptor> typeOrderAnnotation = findAnnotation(theInterceptorClass, Interceptor.class); 619 if (typeOrderAnnotation.isPresent()) { 620 typeOrder = typeOrderAnnotation.get().order(); 621 } 622 return typeOrder; 623 } 624 625 private static String toErrorString(List<String> theParameterTypes) { 626 return theParameterTypes 627 .stream() 628 .sorted() 629 .collect(Collectors.joining(",")); 630 } 631 632}