de.otto.mongodb.profiler.web.OpProfileController.java Source code

Java tutorial

Introduction

Here is the source code for de.otto.mongodb.profiler.web.OpProfileController.java

Source

/*
 *  Copyright 2013 Robert Gacki <robert.gacki@cgi.com>
 *
 *  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 de.otto.mongodb.profiler.web;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import de.otto.mongodb.profiler.AverageMeasure;
import de.otto.mongodb.profiler.ChronoSampler;
import de.otto.mongodb.profiler.ProfiledDatabase;
import de.otto.mongodb.profiler.ProfilerService;
import de.otto.mongodb.profiler.op.DocumentMoveMeasure;
import de.otto.mongodb.profiler.op.OpProfile;
import de.otto.mongodb.profiler.op.OpProfiler;
import de.otto.mongodb.profiler.op.UpdateProfile;
import org.joda.time.DateTime;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UriComponentsBuilder;

import javax.validation.Valid;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;

@Controller
@RequestMapping(OpProfileController.CONTROLLER_RESOURCE)
public class OpProfileController extends AbstractController {

    public static final String CONTROLLER_RESOURCE = "/connections/{connectionId:.+}/databases/{databaseName:.+}/ops";

    private final int maximumGraphValues = 3_000;

    public OpProfileController(ProfilerService profilerService) {
        super(profilerService);
    }

    @RequestMapping(method = RequestMethod.GET, produces = MediaType.TEXT_HTML_VALUE)
    public View showOpProfiles(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            final UriComponentsBuilder uriComponentsBuilder) throws ResourceNotFoundException {

        final String uri = uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString();

        return new RedirectView(uri);
    }

    @RequestMapping(method = RequestMethod.GET, produces = MediaType.TEXT_HTML_VALUE, params = "render=panel")
    public ModelAndView showOpProfilesPanelFragment(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            @RequestParam(value = "renderType", required = false) final String renderTypeValue, Locale locale)
            throws ResourceNotFoundException {

        final OpProfilesPanelViewModel.RenderType renderType;

        if (renderTypeValue == null || renderTypeValue.isEmpty()) {
            renderType = OpProfilesPanelViewModel.RenderType.PANEL;
        } else {
            renderType = OpProfilesPanelViewModel.RenderType.valueOf(renderTypeValue.toUpperCase(Locale.ENGLISH));
        }

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);

        final OpProfiler opProfiler = getProfilerService().getOpProfiler(database);

        final OpProfilesPanelViewModel viewModel = new OpProfilesPanelViewModel(connectionId, databaseName,
                opProfiler, renderType, locale);

        return new ModelAndView("fragment.op-profiles-panel").addObject("model", viewModel);
    }

    @Page(mainNavigation = MainNavigation.DATABASES)
    @RequestMapping(value = "/{id:.+}", method = RequestMethod.GET, produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView showOpProfile(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName, @PathVariable("id") final String id,
            Locale locale) throws ResourceNotFoundException {

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);
        final OpProfile profile = requireProfile(database, id);

        final OpProfilePageViewModel viewModel = new OpProfilePageViewModel(profile, database, locale);

        return new ModelAndView("page.op-profile").addObject("model", viewModel);
    }

    private static final class MovedDocumentsSample {

        public final long time;
        public final long normal;
        public final long moved;
        public final BigDecimal ratio;

        private MovedDocumentsSample(long time, long normal, long moved, BigDecimal ratio) {
            this.time = time;
            this.normal = normal;
            this.moved = moved;
            this.ratio = ratio;
        }
    }

    private static final ChronoSampler.Reduction<DocumentMoveMeasure.Mark, MovedDocumentsSample> MOVED_DOCUMENTS_REDUCTION = new ChronoSampler.Reduction<DocumentMoveMeasure.Mark, MovedDocumentsSample>() {

        @Override
        public MovedDocumentsSample reduce(long time, Deque<DocumentMoveMeasure.Mark> marks) {
            if (marks.isEmpty()) {
                return null;
            }
            long moved = 0;
            long normal = 0;
            for (DocumentMoveMeasure.Mark mark : marks) {
                if (mark.moved) {
                    moved += 1;
                } else {
                    normal += 1;
                }
            }
            return new MovedDocumentsSample(time, normal, moved, DocumentMoveMeasure.ratio(normal, moved));
        }
    };

    @RequestMapping(value = "/{id:.+}/chart-data/moved-documents", method = RequestMethod.GET, produces = {
            JSON_TYPE_1, JSON_TYPE_2 })
    public HttpEntity<String> getMovedDocumentChartData(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName, @PathVariable("id") final String id,
            @RequestParam(value = "sampleRate", required = false) Long sampleRate)
            throws ResourceNotFoundException {

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);
        final OpProfile profile = requireProfile(database, id);

        if (!(profile instanceof UpdateProfile)) {
            throw new ResourceNotFoundException("No update profile found!");
        }

        if (sampleRate == null) {
            sampleRate = 15L;
        }

        final DocumentMoveMeasure measure = ((UpdateProfile) profile).getMoveMeasure();

        final ChronoSampler<DocumentMoveMeasure.Mark, MovedDocumentsSample> sampler = new ChronoSampler<>(
                sampleRate, TimeUnit.MINUTES, MOVED_DOCUMENTS_REDUCTION, lowerBoundary(-24));

        for (DocumentMoveMeasure.Mark mark : measure.getMarks()) {
            sampler.add(mark.time, mark);
        }

        final List<MovedDocumentsSample> samples = sampler.finish();

        final JsonArray ratioValues = new JsonArray();
        final JsonArray totalValues = new JsonArray();
        final JsonArray movedValues = new JsonArray();
        for (MovedDocumentsSample sample : samples) {
            final JsonArray ratioValue = new JsonArray();
            ratioValue.add(new JsonPrimitive(Long.valueOf(sample.time)));
            ratioValue.add(new JsonPrimitive(
                    Integer.valueOf(sample.ratio.movePointRight(2).setScale(0, BigDecimal.ROUND_UP).intValue())));
            ratioValues.add(ratioValue);
            final JsonArray totalValue = new JsonArray();
            totalValue.add(new JsonPrimitive(Long.valueOf(sample.time)));
            totalValue.add(new JsonPrimitive(Long.valueOf(sample.normal + sample.moved)));
            totalValues.add(totalValue);
            final JsonArray movedValue = new JsonArray();
            movedValue.add(new JsonPrimitive(Long.valueOf(sample.time)));
            movedValue.add(new JsonPrimitive(Long.valueOf(sample.moved)));
            movedValues.add(movedValue);
        }

        final JsonObject ratioJson = new JsonObject();
        ratioJson.add("key", new JsonPrimitive("Ratio"));
        ratioJson.add("values", ratioValues);

        final JsonObject totalJson = new JsonObject();
        totalJson.add("key", new JsonPrimitive("Total"));
        totalJson.add("values", totalValues);

        final JsonObject movedJson = new JsonObject();
        movedJson.add("key", new JsonPrimitive("Moved"));
        movedJson.add("values", movedValues);

        final JsonObject json = new JsonObject();
        json.add("ratio", ratioJson);
        json.add("total", totalJson);
        json.add("moved", movedJson);

        return new HttpEntity<>(json.toString());
    }

    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=startProfiling", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View startProfiling(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            final UriComponentsBuilder uriComponentsBuilder) throws ResourceNotFoundException {

        getProfilerService().getOpProfiler(requireDatabase(connectionId, databaseName)).continueProfiling();

        return new RedirectView(uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString());
    }

    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=stopProfiling", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View stopProfiling(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            final UriComponentsBuilder uriComponentsBuilder) throws ResourceNotFoundException {

        getProfilerService().getOpProfiler(requireDatabase(connectionId, databaseName)).stopProfiling();

        return new RedirectView(uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString());
    }

    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=reset", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View resetProfiles(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            final UriComponentsBuilder uriComponentsBuilder) throws ResourceNotFoundException {

        getProfilerService().getOpProfiler(requireDatabase(connectionId, databaseName)).reset();

        return new RedirectView(uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString());
    }

    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=changeProfilingLevel", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View changeProfilingLevel(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            @RequestParam(value = "level") final Integer levelValue,
            final UriComponentsBuilder uriComponentsBuilder) throws ResourceNotFoundException {

        final OpProfiler.ProfilingLevel level = OpProfiler.ProfilingLevel.forValue(levelValue);

        if (level != null) {
            getProfilerService().getOpProfiler(requireDatabase(connectionId, databaseName))
                    .setProfilingLevel(level);
        }

        return new RedirectView(uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString());
    }

    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=explainNextQueries", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View explainNextQueries(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            final UriComponentsBuilder uriComponentsBuilder) throws Exception {

        getProfilerService().getOpProfiler(requireDatabase(connectionId, databaseName)).explainNextQueries();

        return new RedirectView(uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}")
                .fragment("OpProfiles").buildAndExpand(connectionId, databaseName).toUriString());
    }

    @Page
    @RequestMapping(value = "/control", method = RequestMethod.GET, params = "action=changeOutlierConfiguration", produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView showOutlierConfiguration(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName) throws ResourceNotFoundException {

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);

        final OpProfile.OutlierConfiguration config = getProfilerService().getOpProfiler(database)
                .getOutlierConfiguration();

        final OpOutlierConfigurationFormModel model = new OpOutlierConfigurationFormModel();

        if (config != null) {
            model.setEnabled(true);
            model.setIgnoreOutliers(config.ignoreOutliers());
            model.setCaptureOutliers(config.captureOutliers());
        }

        return new ModelAndView("page.op-outlierconfig")
                .addObject("model", new OpOutlierConfigurationPageViewModel(database, config != null))
                .addObject("configuration", model);
    }

    @Page
    @RequestMapping(value = "/control", method = RequestMethod.POST, params = "action=changeOutlierConfiguration", produces = MediaType.TEXT_HTML_VALUE, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public ModelAndView changeOutlierConfiguration(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName,
            @Valid @ModelAttribute("configuration") final OpOutlierConfigurationFormModel model,
            final BindingResult bindingResult, final UriComponentsBuilder uriComponentsBuilder)
            throws ResourceNotFoundException {

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);

        final OpProfiler opProfiler = getProfilerService().getOpProfiler(database);

        if (bindingResult.hasErrors()) {

            final boolean outlierConfiEnabled = model.isEnabled()
                    || getProfilerService().getOpProfiler(database).getOutlierConfiguration() != null;

            return new ModelAndView("page.op-outlierconfig")
                    .addObject("model", new OpOutlierConfigurationPageViewModel(database, outlierConfiEnabled))
                    .addObject("configuration", model);
        }

        final OpProfile.OutlierConfiguration outlierConfig;

        if (model.isEnabled()) {
            outlierConfig = new OpProfile.OutlierConfiguration() {
                @Override
                public boolean ignoreOutliers() {
                    return Boolean.TRUE.equals(model.getIgnoreOutliers());
                }

                @Override
                public boolean captureOutliers() {
                    return Boolean.TRUE.equals(model.getCaptureOutliers());
                }

                @Override
                public AverageMeasure.OutlierStrategy createStrategy() {
                    return model.getStrategy().createStrategy(model);
                }
            };
        } else {
            outlierConfig = null;
        }

        opProfiler.setOutlierConfiguration(outlierConfig);
        opProfiler.reset();

        return new ModelAndView(new RedirectView(
                uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}/ops")
                        .queryParam("action", "changeOutlierConfiguration")
                        .buildAndExpand(connectionId, databaseName).toUriString()));
    }

    @RequestMapping(value = "/{id:.+}/issues", method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public View dismissIssue(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName, @PathVariable("id") final String id,
            @RequestParam("dismiss") final String issueCode, final UriComponentsBuilder uriComponentsBuilder)
            throws Exception {

        requireProfile(requireDatabase(connectionId, databaseName), id).dismissIssue(issueCode);

        return new RedirectView(
                uriComponentsBuilder.path("/connections/{connectionId}/databases/{databaseName}/ops/{id}")
                        .buildAndExpand(connectionId, databaseName, id).toUriString());
    }

    @RequestMapping(value = "/{id:.+}/stats", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
    public HttpEntity<String> getStatsData(@PathVariable("connectionId") final String connectionId,
            @PathVariable("databaseName") final String databaseName, @PathVariable("id") final String id,
            @RequestParam(value = "datum", required = false) final List<String> datum,
            @RequestParam(value = "from", required = false) final Long from,
            @RequestParam(value = "to", required = false) final Long to,
            @RequestParam(value = "min", required = false) final Double min,
            @RequestParam(value = "max", required = false) final Double max,
            @RequestParam(value = "limit", required = false) final Integer limit) throws Exception {

        final ProfiledDatabase database = requireDatabase(connectionId, databaseName);

        final OpProfile profile = requireProfile(database, id);

        final JsonObject json = new JsonObject();
        final JsonObject outliersJson = new JsonObject();

        final JsonObject params = new JsonObject();
        outliersJson.add("params", params);

        final JsonArray datumParamValues = new JsonArray();
        params.add("datum", datumParamValues);
        if (datum != null) {
            for (String datumValue : datum) {
                datumParamValues.add(new JsonPrimitive(datumValue));
            }
        }

        final DateTime lowerTimeBound = from != null ? new DateTime(from.longValue()) : null;
        final DateTime upperTimeBound = to != null ? new DateTime(to.longValue()) : null;

        if (lowerTimeBound != null && upperTimeBound != null && !upperTimeBound.isAfter(lowerTimeBound)) {
            throw new IllegalArgumentException("'to' must not be lower than or equal to 'from'");
        }

        params.add("from", from != null ? new JsonPrimitive(from.longValue()) : null);
        params.add("to", to != null ? new JsonPrimitive(to.longValue()) : null);

        if (min != null && max != null && !(max.longValue() > min.longValue())) {
            throw new IllegalArgumentException("'max' must not be lower than or equal to 'min'");
        }

        params.add("min", min != null ? new JsonPrimitive(min.intValue()) : null);
        params.add("max", max != null ? new JsonPrimitive(max.intValue()) : null);

        if (limit != null && limit.intValue() < 1) {
            throw new IllegalArgumentException("'limit' must not be lower than 1");
        }

        final int maximumValues = limit != null && limit.intValue() > 0 ? limit.intValue() : maximumGraphValues;

        params.add("limit", new JsonPrimitive(maximumValues));

        if (profile.getOutlierConfiguration() != null && profile.getOutlierConfiguration().captureOutliers()) {

            final OutlierGraphDataBuilder builder = new OutlierGraphDataBuilder(datum, maximumValues,
                    lowerTimeBound, upperTimeBound, min, max);

            builder.add("executionTimeOutliers", "Execution time outliers", profile.getAverageMillisOutliers());
            builder.add("readLockAcquisitionTimeOutliers", "Read lock acquisition time outliers",
                    profile.getAverageAcquireReadLockMicrosOutliers());
            builder.add("writeLockAcquisitionTimeOutliers", "Write lock acquisition time outliers",
                    profile.getAverageAcquireWriteLockMicrosOutliers());
            builder.add("readLockTimeOutliers", "Read lock time outliers",
                    profile.getAverageLockedReadMicrosOutliers());
            builder.add("writeLockTimeOutliers", "Write lock time outliers",
                    profile.getAverageLockedWriteMicrosOutliers());

            outliersJson.add("data", builder.getGroups());
            outliersJson.add("lowestTime", new JsonPrimitive(builder.getLowestTime()));
            outliersJson.add("highestTime", new JsonPrimitive(builder.getHighestTime()));

            final JsonArray warningJson = new JsonArray();

            if (!builder.getKeysWithTooMuchData().isEmpty()) {
                final StringBuilder warning = new StringBuilder();
                final JsonArray tooMuchDataFor = new JsonArray();
                for (String key : builder.getKeysWithTooMuchData()) {
                    tooMuchDataFor.add(new JsonPrimitive(key));
                    if (warning.length() > 0) {
                        warning.append(", ");
                    }
                    warning.append(key);
                }
                warningJson.add(new JsonPrimitive(String.format(
                        "The maximum amount of %d values has been exceeded. "
                                + "The following data could not be (completely) returned: %s. "
                                + "Please narrow your filter to get applicable results.",
                        maximumValues, warning.toString())));
            }

            if (warningJson.size() > 0) {
                outliersJson.add("warnings", warningJson);
            }
        }

        json.add("outliers", outliersJson);

        return new HttpEntity<>(json.toString());
    }

    protected OpProfile requireProfile(final ProfiledDatabase database, final String id)
            throws ResourceNotFoundException {

        final OpProfiler profiler = getProfilerService().getOpProfiler(database);

        if (profiler == null) {
            throw new IllegalStateException(
                    String.format("The CollectionProfiler is required to exist for database [%s]!", database));
        }

        final OpProfile profile = profiler.getProfile(id);

        if (profile == null) {
            throw new ResourceNotFoundException(
                    String.format("No query profile found for ID [%s] of database [%s]!", id, database));
        }

        return profile;
    }

    private static class OutlierGraphDataBuilder {

        private final List<String> requestedData;
        private final JsonArray groups;
        private final Long lowerTimeBound;
        private final Long upperTimeBound;
        private final Double lowerValueBound;
        private final Double upperValueBound;
        private final int limit;

        private int valuesLeft;
        private List<String> keysWithTooMuchData;
        private long lowestTime = Long.MAX_VALUE;
        private long highestTime = Long.MIN_VALUE;

        private OutlierGraphDataBuilder(final List<String> requestedData, final int limit,
                final DateTime lowerTimeBound, final DateTime upperTimeBound, final Double lowerValueBound,
                final Double upperValueBound) {

            if (lowerTimeBound != null && upperTimeBound != null && lowerTimeBound.isAfter(upperTimeBound)) {
                throw new IllegalArgumentException("'lowerTimeBound' must be lower than 'upperTimeBound'");
            }

            this.requestedData = requestedData;
            this.groups = new JsonArray();
            this.limit = this.valuesLeft = limit;
            this.keysWithTooMuchData = new ArrayList<>();
            this.lowerTimeBound = lowerTimeBound != null ? lowerTimeBound.getMillis() : null;
            this.upperTimeBound = upperTimeBound != null ? upperTimeBound.getMillis() : null;
            this.lowerValueBound = lowerValueBound;
            this.upperValueBound = upperValueBound;
        }

        public void add(String dataType, String key, Collection<AverageMeasure.Outlier> outliers) {

            // No data available
            if (outliers == null || outliers.isEmpty()) {
                return;
            }

            // Data was requested by the user?
            if (requestedData != null && !requestedData.contains(dataType)) {
                return;
            }

            // Too much data for the limit, then don't display any other group?
            if (groups.size() > 0 && (valuesLeft <= 0 || valuesLeft < outliers.size())) {
                keysWithTooMuchData.add(key);
                return;
            }

            final JsonArray jsonValues = new JsonArray();

            for (AverageMeasure.Outlier outlier : outliers) {

                if (valuesLeft == 0) {
                    keysWithTooMuchData.add(key);
                    break;
                }

                final long time = outlier.getTime();

                if ((lowerTimeBound != null && lowerTimeBound.longValue() > time)
                        || (upperTimeBound != null && upperTimeBound.longValue() < time)) {
                    continue;
                }

                final double value = outlier.getValue();

                if ((lowerValueBound != null && lowerValueBound.longValue() > value)
                        || (upperValueBound != null && upperValueBound.longValue() < value)) {
                    continue;
                }

                final JsonObject json = new JsonObject();
                json.add("x", new JsonPrimitive(time));
                json.add("y", new JsonPrimitive(outlier.getValue()));
                jsonValues.add(json);
                valuesLeft -= 1;
                if (time < lowestTime) {
                    lowestTime = outlier.getTime();
                }
                if (time > highestTime) {
                    highestTime = outlier.getTime();
                }
            }

            if (jsonValues.size() > 0) {
                final JsonObject jsonGroup = new JsonObject();
                jsonGroup.add("key", new JsonPrimitive(key));
                jsonGroup.add("values", jsonValues);
                groups.add(jsonGroup);
            }
        }

        private int getLimit() {
            return limit;
        }

        public JsonArray getGroups() {
            return groups;
        }

        public long getLowestTime() {
            return lowestTime;
        }

        public long getHighestTime() {
            return highestTime;
        }

        public List<String> getKeysWithTooMuchData() {
            return keysWithTooMuchData;
        }
    }

}