All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.solr.handler.admin.ZookeeperInfoHandler Maven / Gradle / Ivy

There is a newer version: 9.7.0
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.solr.handler.admin;

import static org.apache.solr.common.params.CommonParams.OMIT_HEADER;
import static org.apache.solr.common.params.CommonParams.PATH;
import static org.apache.solr.common.params.CommonParams.WT;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.lang.invoke.MethodHandles;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.lucene.util.BytesRef;
import org.apache.solr.cloud.ZkController;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrException.ErrorCode;
import org.apache.solr.common.cloud.ClusterState;
import org.apache.solr.common.cloud.DocCollection;
import org.apache.solr.common.cloud.DocCollection.CollectionStateProps;
import org.apache.solr.common.cloud.OnReconnect;
import org.apache.solr.common.cloud.Replica;
import org.apache.solr.common.cloud.Slice.SliceStateProps;
import org.apache.solr.common.cloud.SolrZkClient;
import org.apache.solr.common.cloud.ZkStateReader;
import org.apache.solr.common.params.MapSolrParams;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.common.util.ContentStream;
import org.apache.solr.common.util.Utils;
import org.apache.solr.core.CoreContainer;
import org.apache.solr.handler.RequestHandlerBase;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.JSONResponseWriter;
import org.apache.solr.response.RawResponseWriter;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.data.Stat;
import org.apache.zookeeper.server.ByteBufferInputStream;
import org.noggit.CharArr;
import org.noggit.JSONWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Zookeeper Info
 *
 * @since solr 4.0
 */
public final class ZookeeperInfoHandler extends RequestHandlerBase {
  private static final String PARAM_DETAIL = "detail";
  private final CoreContainer cores;

  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  // used for custom sorting collection names looking like prefix##
  // only go out to 7 digits (which safely fits in an int)
  private static final Pattern endsWithDigits = Pattern.compile("^(\\D*)(\\d{1,7}?)$");

  public ZookeeperInfoHandler(CoreContainer cc) {
    this.cores = cc;
  }

  @Override
  public String getDescription() {
    return "Fetch Zookeeper contents";
  }

  @Override
  public Category getCategory() {
    return Category.ADMIN;
  }

  @Override
  public Name getPermissionName(AuthorizationContext request) {
    var params = request.getParams();
    String path = params.get(PATH, "");
    String detail = params.get(PARAM_DETAIL, "false");
    if ("/security.json".equalsIgnoreCase(path) && "true".equalsIgnoreCase(detail)) {
      return Name.SECURITY_READ_PERM;
    } else {
      return Name.ZK_READ_PERM;
    }
  }

  /** Enumeration of ways to filter collections on the graph panel. */
  static enum FilterType {
    none,
    name,
    status
  }

  /** Holds state of a single page of collections requested from the cloud panel. */
  static final class PageOfCollections {
    List selected;
    int numFound = 0; // total number of matches (across all pages)
    int start = 0;
    int rows = -1;
    FilterType filterType;
    String filter;

    PageOfCollections(int start, int rows, FilterType filterType, String filter) {
      this.start = start;
      this.rows = rows;
      this.filterType = filterType;
      this.filter = filter;
    }

    void selectPage(List collections) {
      numFound = collections.size();
      // start with full set and then find the sublist for the desired selected
      selected = collections;

      if (rows > 0) { // paging desired
        if (start > numFound) start = 0; // this might happen if they applied a new filter

        int lastIndex = Math.min(start + rows, numFound);
        if (start > 0 || lastIndex < numFound) selected = collections.subList(start, lastIndex);
      }
    }

    /** Filters a list of collections by name if applicable. */
    List applyNameFilter(List collections) {

      if (filterType != FilterType.name || filter == null)
        return collections; // name filter doesn't apply

      // typically, a user will type a prefix and then *, e.g. tj*
      // when they really mean tj.*
      String regexFilter =
          (!filter.endsWith(".*") && filter.endsWith("*"))
              ? filter.substring(0, filter.length() - 1) + ".*"
              : filter;

      // case-insensitive
      if (!regexFilter.startsWith("(?i)")) regexFilter = "(?i)" + regexFilter;

      Pattern filterRegex = Pattern.compile(regexFilter);
      List filtered = new ArrayList();
      for (String next : collections) {
        if (matches(filterRegex, next)) filtered.add(next);
      }

      return filtered;
    }

    /**
     * Walk the collection state JSON object to see if it has any replicas that match the state the
     * user is filtering by.
     */
    @SuppressWarnings("unchecked")
    final boolean matchesStatusFilter(Map collectionState, Set liveNodes) {

      if (filterType != FilterType.status || filter == null || filter.length() == 0)
        return true; // no status filter, so all match

      boolean isHealthy = true; // means all replicas for all shards active
      boolean hasDownedShard = false; // means one or more shards is down
      boolean replicaInRecovery = false;

      Map shards =
          (Map) collectionState.get(CollectionStateProps.SHARDS);
      for (Object o : shards.values()) {
        boolean hasActive = false;
        Map shard = (Map) o;
        Map replicas = (Map) shard.get(SliceStateProps.REPLICAS);
        for (Object value : replicas.values()) {
          Map replicaState = (Map) value;
          Replica.State coreState =
              Replica.State.getState((String) replicaState.get(ZkStateReader.STATE_PROP));
          String nodeName = (String) replicaState.get(ZkStateReader.NODE_NAME_PROP);

          // state can lie to you if the node is offline, so need to reconcile with live_nodes too
          if (!liveNodes.contains(nodeName))
            coreState = Replica.State.DOWN; // not on a live node, so must be down

          if (coreState == Replica.State.ACTIVE) {
            hasActive = true; // assumed no replicas active and found one that is for this shard
          } else {
            if (coreState == Replica.State.RECOVERING) {
              replicaInRecovery = true;
            }
            isHealthy = false; // assumed healthy and found one replica that is not
          }
        }

        if (!hasActive) hasDownedShard = true; // this is bad
      }

      if ("healthy".equals(filter)) {
        return isHealthy;
      } else if ("degraded".equals(filter)) {
        return !hasDownedShard && !isHealthy; // means no shards offline but not 100% healthy either
      } else if ("downed_shard".equals(filter)) {
        return hasDownedShard;
      } else if (Replica.State.getState(filter) == Replica.State.RECOVERING) {
        return !isHealthy && replicaInRecovery;
      }

      return true;
    }

    final boolean matches(final Pattern filter, final String collName) {
      return filter.matcher(collName).matches();
    }

    String getPagingHeader() {
      return start
          + "|"
          + rows
          + "|"
          + numFound
          + "|"
          + (filterType != null ? filterType.toString() : "")
          + "|"
          + (filter != null ? filter : "");
    }

    @Override
    public String toString() {
      return getPagingHeader();
    }
  }

  /**
   * Supports paged navigation of collections on the cloud panel. To avoid serving stale collection
   * data, this object watches the /collections znode, which will change if a collection is added or
   * removed.
   */
  static final class PagedCollectionSupport implements Watcher, Comparator, OnReconnect {

    // this is the full merged list of collections from ZooKeeper
    private List cachedCollections;

    /** If the list of collections changes, mark the cache as stale. */
    @Override
    public void process(WatchedEvent event) {
      // session events are not change events, and do not remove the watcher
      if (Event.EventType.None.equals(event.getType())) {
        return;
      }
      synchronized (this) {
        cachedCollections = null;
      }
    }

    /** Create a merged view of all collections from /collections/?/state.json */
    private synchronized List getCollections(SolrZkClient zkClient)
        throws KeeperException, InterruptedException {
      if (cachedCollections == null) {
        // cache is stale, rebuild the full list ...
        cachedCollections = new ArrayList();

        List fromZk = zkClient.getChildren("/collections", this, true);
        if (fromZk != null) cachedCollections.addAll(fromZk);

        // sort the final merged set of collections
        cachedCollections.sort(this);
      }

      return cachedCollections;
    }

    /** Gets the requested page of collections after applying filters and offsets. */
    public void fetchPage(PageOfCollections page, SolrZkClient zkClient)
        throws KeeperException, InterruptedException {

      List children = getCollections(zkClient);
      page.selected = children; // start with the page being the full list

      // activate paging (if disabled) for large collection sets
      if (page.start == 0 && page.rows == -1 && page.filter == null && children.size() > 10) {
        page.rows = 20;
        page.start = 0;
      }

      // apply the name filter if supplied (we don't need to pull state
      // data from ZK to do name filtering
      if (page.filterType == FilterType.name && page.filter != null)
        children = page.applyNameFilter(children);

      // a little hacky ... we can't select the page when filtering by
      // status until reading all status objects from ZK
      if (page.filterType != FilterType.status) page.selectPage(children);
    }

    @Override
    public int compare(String left, String right) {
      if (left == null) return -1;

      if (left.equals(right)) return 0;

      // sort lexically unless the two collection names start with the same base prefix
      // and end in a number (which is a common enough naming scheme to have direct
      // support for it)
      Matcher leftMatcher = endsWithDigits.matcher(left);
      if (leftMatcher.matches()) {
        Matcher rightMatcher = endsWithDigits.matcher(right);
        if (rightMatcher.matches()) {
          String leftGroup1 = leftMatcher.group(1);
          String rightGroup1 = rightMatcher.group(1);
          if (leftGroup1.equals(rightGroup1)) {
            // both start with the same prefix ... compare indexes
            // using longs here as we don't know how long the 2nd group is
            int leftGroup2 = Integer.parseInt(leftMatcher.group(2));
            int rightGroup2 = Integer.parseInt(rightMatcher.group(2));
            return (leftGroup2 > rightGroup2) ? 1 : ((leftGroup2 == rightGroup2) ? 0 : -1);
          }
        }
      }
      return left.compareTo(right);
    }

    /** Called after a ZooKeeper session expiration occurs */
    @Override
    public void command() {
      // we need to re-establish the watcher on the collections list after session expires
      synchronized (this) {
        cachedCollections = null;
      }
    }
  }

  private PagedCollectionSupport pagingSupport;

  @Override
  @SuppressWarnings({"unchecked"})
  public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
    final SolrParams params = req.getParams();
    Map map = Map.of(WT, "raw", OMIT_HEADER, "true");
    req.setParams(SolrParams.wrapDefaults(new MapSolrParams(map), params));
    synchronized (this) {
      if (pagingSupport == null) {
        pagingSupport = new PagedCollectionSupport();
        ZkController zkController = cores.getZkController();
        if (zkController != null) {
          // get notified when the ZK session expires (so we can clear the cached collections and
          // rebuild)
          zkController.addOnReconnectListener(pagingSupport);
        }
      }
    }

    String path = params.get(PATH);

    if (params.get("addr") != null) {
      throw new SolrException(ErrorCode.BAD_REQUEST, "Illegal parameter \"addr\"");
    }

    String detailS = params.get(PARAM_DETAIL);
    boolean detail = detailS != null && detailS.equals("true");

    String dumpS = params.get("dump");
    boolean dump = dumpS != null && dumpS.equals("true");

    int start = params.getInt("start", 0); // Note start ignored if rows not specified
    int rows = params.getInt("rows", -1);

    String filterType = params.get("filterType");
    if (filterType != null) {
      filterType = filterType.trim().toLowerCase(Locale.ROOT);
      if (filterType.length() == 0) filterType = null;
    }
    FilterType type = (filterType != null) ? FilterType.valueOf(filterType) : FilterType.none;

    String filter = (type != FilterType.none) ? params.get("filter") : null;
    if (filter != null) {
      filter = filter.trim();
      if (filter.length() == 0) filter = null;
    }

    ZKPrinter printer = new ZKPrinter(cores.getZkController());
    printer.detail = detail;
    printer.dump = dump;
    boolean isGraphView = "graph".equals(params.get("view"));
    // There is no znode /clusterstate.json (removed in Solr 9), but we do as if there's one and
    // return collection listing. Need to change services.js if cleaning up here, collection list is
    // used from Admin UI Cloud - Graph
    boolean paginateCollections = (isGraphView && "/clusterstate.json".equals(path));
    printer.page = paginateCollections ? new PageOfCollections(start, rows, type, filter) : null;
    printer.pagingSupport = pagingSupport;

    try {
      if (paginateCollections) {
        // List collections and allow pagination, but no specific znode info like when looking at a
        // normal ZK path
        printer.printPaginatedCollections();
      } else {
        printer.print(path);
      }
    } finally {
      printer.close();
    }
    rsp.getValues().add(RawResponseWriter.CONTENT, printer);
  }

  // --------------------------------------------------------------------------------------
  //
  // --------------------------------------------------------------------------------------

  static class ZKPrinter implements ContentStream {
    static boolean FULLPATH_DEFAULT = false;

    boolean indent = true;
    boolean fullpath = FULLPATH_DEFAULT;
    boolean detail = false;
    boolean dump = false;

    String keeperAddr; // the address we're connected to

    final Utils.BAOS baos = new Utils.BAOS();
    final Writer out = new OutputStreamWriter(baos, StandardCharsets.UTF_8);
    SolrZkClient zkClient;

    PageOfCollections page;
    PagedCollectionSupport pagingSupport;
    ZkController zkController;

    public ZKPrinter(ZkController controller) throws IOException {
      this.zkController = controller;
      keeperAddr = controller.getZkServerAddress();
      zkClient = controller.getZkClient();
    }

    public void close() {
      try {
        out.flush();
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }

    // main entry point for printing from path
    void print(String path) throws IOException {
      if (zkClient == null) {
        return;
      }

      // normalize path
      if (path == null) {
        path = "/";
      } else {
        path = path.trim();
        if (path.length() == 0) {
          path = "/";
        }
      }

      if (path.endsWith("/") && path.length() > 1) {
        path = path.substring(0, path.length() - 1);
      }

      int idx = path.lastIndexOf('/');
      String parent = idx >= 0 ? path.substring(0, idx) : path;
      if (parent.length() == 0) {
        parent = "/";
      }

      CharArr chars = new CharArr();
      JSONWriter json = new JSONWriter(chars, 2);
      json.startObject();

      if (detail) {
        if (!printZnode(json, path)) {
          return;
        }
        json.writeValueSeparator();
      }

      json.writeString("tree");
      json.writeNameSeparator();
      json.startArray();
      if (!printTree(json, path)) {
        return; // there was an error
      }
      json.endArray();
      json.endObject();
      out.write(chars.toString());
    }

    // main entry point for printing collections
    @SuppressWarnings("unchecked")
    void printPaginatedCollections() throws IOException {
      SortedMap collectionStates;
      try {
        // support paging of the collections graph view (in case there are many collections)
        // fetch the requested page of collections and then retrieve the state for each
        pagingSupport.fetchPage(page, zkClient);
        // keep track of how many collections match the filter
        boolean applyStatusFilter = (page.filterType == FilterType.status && page.filter != null);
        List matchesStatusFilter = applyStatusFilter ? new ArrayList<>() : null;
        ClusterState cs = zkController.getZkStateReader().getClusterState();
        Set liveNodes = applyStatusFilter ? cs.getLiveNodes() : null;

        collectionStates = new TreeMap<>(pagingSupport);
        for (String collection : page.selected) {
          DocCollection dc = cs.getCollectionOrNull(collection);
          if (dc != null) {
            // TODO: for collections with perReplicaState, a ser/deser to JSON was needed to get the
            // state to render correctly for the UI?
            Map collectionState = dc.toMap(new LinkedHashMap<>());
            if (applyStatusFilter) {
              // verify this collection matches the filtered state
              if (page.matchesStatusFilter(collectionState, liveNodes)) {
                matchesStatusFilter.add(collection);
                collectionStates.put(
                    collection, ClusterStatus.postProcessCollectionJSON(collectionState));
              }
            } else {
              collectionStates.put(
                  collection, ClusterStatus.postProcessCollectionJSON(collectionState));
            }
          }
        }

        if (applyStatusFilter) {
          // update the paged navigation info after applying the status filter
          page.selectPage(matchesStatusFilter);

          // rebuild the Map of state data
          SortedMap map = new TreeMap(pagingSupport);
          for (String next : page.selected) map.put(next, collectionStates.get(next));
          collectionStates = map;
        }
      } catch (KeeperException | InterruptedException e) {
        writeError(500, e.toString());
        return;
      }

      CharArr chars = new CharArr();
      JSONWriter json = new JSONWriter(chars, 2);
      json.startObject();

      json.writeString("znode");
      json.writeNameSeparator();
      json.startObject();

      // For some reason, without this the Json is badly formed
      writeKeyValue(json, PATH, "Undefined", true);

      if (collectionStates != null) {
        CharArr collectionOut = new CharArr();
        new JSONWriter(collectionOut, 2).write(collectionStates);
        writeKeyValue(json, "data", collectionOut.toString(), false);
      }

      writeKeyValue(json, "paging", page.getPagingHeader(), false);

      json.endObject();
      json.endObject();
      out.write(chars.toString());
    }

    void writeError(int code, String msg) throws IOException {
      throw new SolrException(ErrorCode.getErrorCode(code), msg);
      /*response.setStatus(code);

      CharArr chars = new CharArr();
      JSONWriter w = new JSONWriter(chars, 2);
      w.startObject();
      w.indent();
      w.writeString("status");
      w.writeNameSeparator();
      w.write(code);
      w.writeValueSeparator();
      w.indent();
      w.writeString("error");
      w.writeNameSeparator();
      w.writeString(msg);
      w.endObject();

      out.write(chars.toString());*/
    }

    boolean printTree(JSONWriter json, String path) throws IOException {
      String label = path;
      if (!fullpath) {
        int idx = path.lastIndexOf('/');
        label = idx > 0 ? path.substring(idx + 1) : path;
      }
      json.startObject();
      writeKeyValue(json, "text", label, true);
      json.writeValueSeparator();
      json.writeString("a_attr");
      json.writeNameSeparator();
      json.startObject();
      String href =
          "admin/zookeeper?detail=true&path=" + URLEncoder.encode(path, StandardCharsets.UTF_8);
      writeKeyValue(json, "href", href, true);
      json.endObject();

      Stat stat = new Stat();
      try {
        // Trickily, the call to zkClient.getData fills in the stat variable
        byte[] data = zkClient.getData(path, null, stat, true);

        if (stat.getEphemeralOwner() != 0) {
          writeKeyValue(json, "ephemeral", true, false);
          writeKeyValue(json, "version", stat.getVersion(), false);
        }

        if (dump) {
          json.writeValueSeparator();
          printZnode(json, path);
        }

      } catch (IllegalArgumentException e) {
        // path doesn't exist (must have been removed)
        writeKeyValue(json, "warning", "(path gone)", false);
      } catch (KeeperException e) {
        writeKeyValue(json, "warning", e.toString(), false);
        log.warn("Keeper Exception", e);
      } catch (InterruptedException e) {
        writeKeyValue(json, "warning", e.toString(), false);
        log.warn("InterruptedException", e);
      }

      if (stat.getNumChildren() > 0) {
        json.writeValueSeparator();
        if (indent) {
          json.indent();
        }
        json.writeString("children");
        json.writeNameSeparator();
        json.startArray();

        try {
          List children = zkClient.getChildren(path, null, true);
          java.util.Collections.sort(children);

          boolean first = true;
          for (String child : children) {
            if (!first) {
              json.writeValueSeparator();
            }

            String childPath = path + (path.endsWith("/") ? "" : "/") + child;
            if (!printTree(json, childPath)) {
              return false;
            }
            first = false;
          }
        } catch (KeeperException e) {
          writeError(500, e.toString());
          return false;
        } catch (InterruptedException e) {
          writeError(500, e.toString());
          return false;
        } catch (IllegalArgumentException e) {
          // path doesn't exist (must have been removed)
          json.writeString("(children gone)");
        }

        json.endArray();
      }

      json.endObject();
      return true;
    }

    String time(long ms) {
      return (new Date(ms)).toString() + " (" + ms + ")";
    }

    public void writeKeyValue(JSONWriter json, String k, Object v, boolean isFirst) {
      if (!isFirst) {
        json.writeValueSeparator();
      }
      if (indent) {
        json.indent();
      }
      json.writeString(k);
      json.writeNameSeparator();
      json.write(v);
    }

    boolean printZnode(JSONWriter json, String path) throws IOException {
      try {
        String dataStr = null;
        String dataStrErr = null;
        Stat stat = new Stat();
        // Trickily, the call to zkClient.getData fills in the stat variable
        byte[] data = zkClient.getData(path, null, stat, true);
        if (null != data) {
          try {
            dataStr = (new BytesRef(data)).utf8ToString();
          } catch (Exception e) {
            dataStrErr = "data is not parsable as a utf8 String: " + e.toString();
          }
        }

        json.writeString("znode");
        json.writeNameSeparator();
        json.startObject();

        writeKeyValue(json, PATH, path, true);

        json.writeValueSeparator();
        json.writeString("prop");
        json.writeNameSeparator();
        json.startObject();
        writeKeyValue(json, "version", stat.getVersion(), true);
        writeKeyValue(json, "aversion", stat.getAversion(), false);
        writeKeyValue(json, "children_count", stat.getNumChildren(), false);
        writeKeyValue(json, "ctime", time(stat.getCtime()), false);
        writeKeyValue(json, "cversion", stat.getCversion(), false);
        writeKeyValue(json, "czxid", stat.getCzxid(), false);
        writeKeyValue(json, "ephemeralOwner", stat.getEphemeralOwner(), false);
        writeKeyValue(json, "mtime", time(stat.getMtime()), false);
        writeKeyValue(json, "mzxid", stat.getMzxid(), false);
        writeKeyValue(json, "pzxid", stat.getPzxid(), false);
        writeKeyValue(json, "dataLength", stat.getDataLength(), false);
        if (null != dataStrErr) {
          writeKeyValue(json, "dataNote", dataStrErr, false);
        }
        json.endObject();

        if (null != dataStr) {
          writeKeyValue(json, "data", dataStr, false);
        }

        if (page != null) {
          writeKeyValue(json, "paging", page.getPagingHeader(), false);
        }

        json.endObject();
      } catch (KeeperException e) {
        writeError(500, e.toString());
        return false;
      } catch (InterruptedException e) {
        writeError(500, e.toString());
        return false;
      }
      return true;
    }

    /* @Override
        public void write(OutputStream os) throws IOException {
          ByteBuffer bytes = baos.getByteBuffer();
          os.write(bytes.array(),0,bytes.limit());
        }
    */
    @Override
    public String getName() {
      return null;
    }

    @Override
    public String getSourceInfo() {
      return null;
    }

    @Override
    public String getContentType() {
      return JSONResponseWriter.CONTENT_TYPE_JSON_UTF8;
    }

    @Override
    public Long getSize() {
      return null;
    }

    @Override
    public InputStream getStream() throws IOException {
      return new ByteBufferInputStream(baos.getByteBuffer());
    }

    @Override
    public Reader getReader() throws IOException {
      return null;
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy