Как реализовать Java-приложение с одним экземпляром?

StackOverflow https://stackoverflow.com/questions/177189

  •  05-07-2019
  •  | 
  •  

Вопрос

Иногда я вижу много приложений, таких как msn, проигрыватель Windows media и т.д., Которые являются приложениями с одним экземпляром (когда пользователь выполняется во время работы приложения, новый экземпляр приложения не создается).

В C # я использую Mutex класс для этого, но я не знаю, как это сделать на Java.

Это было полезно?

Решение

Если я поверю в это Статья, по :

попытка первого экземпляра открыть прослушивающий сокет в интерфейсе localhost.Если он способен открыть сокет, предполагается, что это первый экземпляр приложения, который будет запущен.Если нет, то предполагается, что экземпляр этого приложения уже запущен.Новый экземпляр должен уведомить существующий экземпляр о попытке запуска, затем завершить работу.Существующий экземпляр вступает во владение после получения уведомления и запускает событие для прослушивателя, который обрабатывает действие.

Примечание: Ахэ упоминает в комментарии, что с помощью InetAddress.getLocalHost() это может быть сложно:

  • это не работает должным образом в DHCP-среде, поскольку возвращаемый адрес зависит от того, имеет ли компьютер доступ к сети.
    Решение состояло в том, чтобы открыть соединение с InetAddress.getByAddress(new byte[] {127, 0, 0, 1});
    Вероятно, это связано с ошибка 4435662.
  • Я также нашел ошибка 4665037 который сообщает о результатах, отличных от ожидаемых getLocalHost:обратный IP-адрес машины, по сравнениюФактические результаты :Возврат 127.0.0.1.

удивительно, что у нас есть getLocalHost Возврат 127.0.0.1 в Linux, но не в Windows.


Или вы можете использовать ManagementFactory объект.Как было объяснено здесь:

Тот Самый getMonitoredVMs(int processPid) метод получает в качестве параметра текущий PID приложения и улавливает имя приложения, которое вызывается из командной строки, например, приложение было запущено из c:\java\app\test.jar path, тогда значение переменной равно "c:\\java\\app\\test.jar".Таким образом, мы поймаем только название приложения в строке 17 приведенного ниже кода.
После этого мы ищем в JVM другой процесс с тем же именем, если мы нашли его и PID приложения отличается, это означает, что это второй экземпляр приложения.

JNLP предлагает также SingleInstanceListener

Другие советы

Я использую следующий метод в основном методе. Это самый простой, надежный и наименее навязчивый метод, который я когда-либо видел, поэтому я решил поделиться им.

private static boolean lockInstance(final String lockFile) {
    try {
        final File file = new File(lockFile);
        final RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
        final FileLock fileLock = randomAccessFile.getChannel().tryLock();
        if (fileLock != null) {
            Runtime.getRuntime().addShutdownHook(new Thread() {
                public void run() {
                    try {
                        fileLock.release();
                        randomAccessFile.close();
                        file.delete();
                    } catch (Exception e) {
                        log.error("Unable to remove lock file: " + lockFile, e);
                    }
                }
            });
            return true;
        }
    } catch (Exception e) {
        log.error("Unable to create and/or lock file: " + lockFile, e);
    }
    return false;
}

Если приложение. имеет графический интерфейс, запустите его с JWS и используйте SingleInstanceService . См. демонстрационную версию. примера SingleInstanceService для (демонстрационного и) кода.

Да, это действительно достойный ответ для приложения Eclipse RCP eclipse для одного экземпляра. ниже мой код

в application.java

if(!isFileshipAlreadyRunning()){
        MessageDialog.openError(display.getActiveShell(), "Fileship already running", "Another instance of this application is already running.  Exiting.");
        return IApplication.EXIT_OK;
    } 


private static boolean isFileshipAlreadyRunning() {
    // socket concept is shown at http://www.rbgrn.net/content/43-java-single-application-instance
    // but this one is really great
    try {
        final File file = new File("FileshipReserved.txt");
        final RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw");
        final FileLock fileLock = randomAccessFile.getChannel().tryLock();
        if (fileLock != null) {
            Runtime.getRuntime().addShutdownHook(new Thread() {
                public void run() {
                    try {
                        fileLock.release();
                        randomAccessFile.close();
                        file.delete();
                    } catch (Exception e) {
                        //log.error("Unable to remove lock file: " + lockFile, e);
                    }
                }
            });
            return true;
        }
    } catch (Exception e) {
       // log.error("Unable to create and/or lock file: " + lockFile, e);
    }
    return false;
}

Для этого мы используем блокировку файлов (захватываем эксклюзивную блокировку волшебного файла в каталоге данных приложения пользователя), но в первую очередь мы заинтересованы в предотвращении запуска нескольких экземпляров.

Если вы пытаетесь, чтобы второй экземпляр передавал аргументы командной строки и т.д...в первом случае использование сокет-соединения на localhost убьет двух зайцев одним выстрелом.Общий алгоритм:

  • При запуске попробуйте открыть прослушиватель на порту XXXX на localhost
  • в случае сбоя откройте запись на этот порт на localhost и отправьте аргументы командной строки, затем завершите работу
  • в противном случае прослушайте порт XXXXX на localhost.Когда вы получите аргументы командной строки, обработайте их так, как если бы приложение было запущено с помощью этой командной строки.

Я нашел решение, немного мультяшное объяснение, но все еще работает в большинстве случаев. Он использует простой старый материал для создания файла блокировки, но в совершенно ином виде:

http://javalandscape.blogspot.com/ 2008/07 / одного экземпляра из-ваш-application.html

Я думаю, что это поможет тем, у кого строгий брандмауэр.

Вы можете использовать библиотеку JUnique. Он обеспечивает поддержку для запуска Java-приложения с одним экземпляром и с открытым исходным кодом.

http://www.sauronsoftware.it/projects/junique/

  

Библиотека JUnique может использоваться для предотвращения одновременного запуска пользователя.   время больше экземпляров одного и того же приложения Java.

     

JUnique реализует блокировки и каналы связи, общие для всех   экземпляры JVM, запущенные одним и тем же пользователем.

public static void main(String[] args) {
    String appId = "myapplicationid";
    boolean alreadyRunning;
    try {
        JUnique.acquireLock(appId, new MessageHandler() {
            public String handle(String message) {
                // A brand new argument received! Handle it!
                return null;
            }
        });
        alreadyRunning = false;
    } catch (AlreadyLockedException e) {
        alreadyRunning = true;
    }
    if (!alreadyRunning) {
        // Start sequence here
    } else {
        for (int i = 0; i < args.length; i++) {
            JUnique.sendMessage(appId, args[0]));
        }
    }
}

В этом случае он создает блокировки файлов в папке% USER_DATA% /. junique и создает сокет сервера на произвольном порту для каждого уникального идентификатора приложения, который позволяет отправлять / получать сообщения между приложениями Java.

В Windows вы можете использовать launch4j .

Класс ManagementFactory, поддерживаемый в J2SE 5.0 или более поздней версии подробно

но теперь я использую J2SE 1.4, и я нашел этот http://audiprimadhanty.wordpress.com/2008/06/30/ensuring-one-instance-of-application-running-at-one-time/ , но Я никогда не проверяю. Что вы думаете об этом?

Вы можете попробовать использовать API настроек. Это не зависит от платформы.

Более общим способом ограничения количества экземпляров на одном компьютере или даже во всей сети является использование сокета многоадресной рассылки.

Использование сокета многоадресной рассылки позволяет вам передавать сообщение любому количеству экземпляров вашего приложения, некоторые из которых могут находиться на физически удаленных компьютерах в корпоративной сети.

Таким образом, вы можете включить множество типов конфигураций, чтобы управлять такими вещами, как

  • Один или несколько экземпляров на машину
  • Один или несколько экземпляров в сети (например, управление установками на клиентском сайте)

Поддержка многоадресной рассылки Java осуществляется через java.net упаковка с Многоадресный сокет & DatagramSocket - хранилище данных являются основными инструментами.

Примечание:Многоадресные сокеты не гарантируют доставку пакетов данных, поэтому вам следует использовать инструмент, построенный поверх многоадресных сокетов, например JГруппы.JГруппы делает гарантируйте доставку всех данных.Это один-единственный jar-файл с очень простым API.

JGroups существует уже некоторое время и имеет несколько впечатляющих применений в промышленности, например, он лежит в основе механизма кластеризации JBoss и передает данные во все экземпляры кластера.

Использовать JGroups, чтобы ограничить количество экземпляров приложения (скажем, на компьютере или в сети:к количеству лицензий, купленных клиентом) концептуально очень проста :

  • При запуске вашего приложения каждый экземпляр пытается присоединиться к именованной группе, например "My Great App Group".Вы настроили эту группу таким образом, чтобы в нее входили 0, 1 или N участников
  • Когда количество участников группы превышает то, что вы для нее настроили..ваше приложение должно отказаться запускаться.

Вы можете открыть файл с отображением в памяти, а затем посмотреть, открыт ли этот файл уже.если он уже открыт, вы можете вернуться из main.

Другими способами является использование файлов блокировки (стандартная практика unix).Еще один способ - поместить что-то в буфер обмена при запуске main после проверки, есть ли что-то уже в буфере обмена.

В противном случае вы можете открыть сокет в режиме прослушивания (ServerSocket).Сначала попробуйте подключиться к разъему hte ;если вы не можете подключиться, то откройте serversocket.если вы подключаетесь, то знаете, что другой экземпляр уже запущен.

Таким образом, практически любой системный ресурс может быть использован для получения информации о том, что приложение запущено.

BR, ~A

Для этого я использовал сокеты, и в зависимости от того, находится ли приложение на стороне клиента или сервера, поведение немного отличается:

  • клиентская сторона :если экземпляр уже существует (я не могу прослушивать определенный порт) Я передам параметры приложения и завершу работу (возможно, вы захотите выполнить некоторые действия в предыдущем экземпляре), если нет, я запущу приложение.
  • серверная сторона :если экземпляр уже существует, я напечатаю сообщение и завершу работу, если нет, я запущу приложение.
public class SingleInstance {
    public static final String LOCK = System.getProperty("user.home") + File.separator + "test.lock";
    public static final String PIPE = System.getProperty("user.home") + File.separator + "test.pipe";
    private static JFrame frame = null;

    public static void main(String[] args) {
        try {
            FileChannel lockChannel = new RandomAccessFile(LOCK, "rw").getChannel();
            FileLock flk = null; 
            try {
                flk = lockChannel.tryLock();
            } catch(Throwable t) {
                t.printStackTrace();
            }
            if (flk == null || !flk.isValid()) {
                System.out.println("alread running, leaving a message to pipe and quitting...");
                FileChannel pipeChannel = null;
                try {
                    pipeChannel = new RandomAccessFile(PIPE, "rw").getChannel();
                    MappedByteBuffer bb = pipeChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1);
                    bb.put(0, (byte)1);
                    bb.force();
                } catch (Throwable t) {
                    t.printStackTrace();
                } finally {
                    if (pipeChannel != null) {
                        try {
                            pipeChannel.close();
                        } catch (Throwable t) {
                            t.printStackTrace();
                        }
                    } 
                }
                System.exit(0);
            }
            //We do not release the lock and close the channel here, 
            //  which will be done after the application crashes or closes normally. 
            SwingUtilities.invokeLater(
                new Runnable() {
                    public void run() {
                        createAndShowGUI();
                    }
                }
            );

            FileChannel pipeChannel = null;
            try {
                pipeChannel = new RandomAccessFile(PIPE, "rw").getChannel();
                MappedByteBuffer bb = pipeChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1);
                while (true) {
                    byte b = bb.get(0);
                    if (b > 0) {
                        bb.put(0, (byte)0);
                        bb.force();
                        SwingUtilities.invokeLater(
                            new Runnable() {
                                public void run() {
                                    frame.setExtendedState(JFrame.NORMAL);
                                    frame.setAlwaysOnTop(true);
                                    frame.toFront();
                                    frame.setAlwaysOnTop(false);
                                }
                            }
                        );
                    }
                    Thread.sleep(1000);
                }
            } catch (Throwable t) {
                t.printStackTrace();
            } finally {
                if (pipeChannel != null) {
                    try {
                        pipeChannel.close();
                    } catch (Throwable t) {
                        t.printStackTrace();
                    } 
                } 
            }
        } catch(Throwable t) {
            t.printStackTrace();
        } 
    }

    public static void createAndShowGUI() {

        frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(800, 650);
        frame.getContentPane().add(new JLabel("MAIN WINDOW", 
                    SwingConstants.CENTER), BorderLayout.CENTER);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
}

Редактировать:Вместо использования этого подхода WatchService можно было бы использовать простой поток таймера продолжительностью 1 секунда для проверки, существует ли indicatorFile.() .Удалите его, затем запустите приложение toFront().

Редактировать:Я хотел бы знать, почему этот вопрос был отклонен.Это лучшее решение, которое я видел до сих пор.Например.подход с использованием серверного сокета завершается неудачей, если другое приложение уже прослушивает порт.

Просто скачайте Microsoft Windows Sysinternals TCPView ( Просмотр TCPView) (или используйте netstat), запустите его, отсортируйте по "Состоянию", найдите блок строк с надписью "ПРОСЛУШИВАНИЕ", выберите тот, чей удаленный адрес указывает имя вашего компьютера, вставьте этот порт в ваше новое решение-Socket().В моей реализации этого я могу каждый раз приводить к сбою.И это логичный, потому что это сама основа подхода.Или чего я не понимаю относительно того, как это реализовать?

Пожалуйста, сообщите мне, если и в чем я ошибаюсь по этому поводу!

Мое мнение - которое я прошу вас опровергнуть, если это возможно, - заключается в том, что разработчикам рекомендуется использовать в производственном коде подход, который приведет к сбою по крайней мере в 1 из примерно 60 000 случаев.И если эта точка зрения окажется правильной, то она может абсолютно нет несмотря на то, что представленное решение, в котором нет этой проблемы, отклонено и подвергнуто критике за объем кода.

Недостатки сокет-подхода в сравнении:

  • Сбой происходит, если выбран неправильный лотерейный билет (номер порта).
  • Сбой в многопользовательской среде:Одновременно запускать приложение может только один пользователь.(Мой подход пришлось бы немного изменить, чтобы создать файл (ы) в дереве пользователей, но это тривиально.)
  • Сбой происходит, если правила брандмауэра слишком строгие.
  • Заставляет подозрительных пользователей (которых я действительно встречал в дикой природе) задуматься, что за махинации вы затеваете, когда ваш текстовый редактор запрашивает серверный сокет.

У меня только что появилась хорошая идея о том, как решить проблему взаимодействия нового экземпляра с существующим экземпляром Java таким образом, чтобы это работало в каждой системе.Итак, я приготовил это занятие примерно за два часа.Работает как заклинание :D

Это основано на Робертподход к блокировке файлов (также на этой странице), который я использую с тех пор.Сообщить уже запущенному экземпляру, что другой экземпляр пытался запуститься (но не сделал этого)...файл создается и немедленно удаляется, и первый экземпляр использует WatchService для обнаружения изменения содержимого этой папки.Я не могу поверить, что, по-видимому, это новая идея, учитывая, насколько фундаментальна проблема.

Это можно легко изменить на просто Создать и не удалять файл, а затем в него может быть помещена информация, которую соответствующий экземпляр может оценить, напримервведите аргументы командной строки - и соответствующий экземпляр сможет затем выполнить удаление.Лично мне нужно было только знать, когда восстановить окно моего приложения и отправить его на фронт.

Пример использования:

public static void main(final String[] args) {

    // ENSURE SINGLE INSTANCE
    if (!SingleInstanceChecker.INSTANCE.isOnlyInstance(Main::otherInstanceTriedToLaunch, false)) {
        System.exit(0);
    }

    // launch rest of application here
    System.out.println("Application starts properly because it's the only instance.");
}

private static void otherInstanceTriedToLaunch() {
    // Restore your application window and bring it to front.
    // But make sure your situation is apt: This method could be called at *any* time.
    System.err.println("Deiconified because other instance tried to start.");
}

Вот такой класс:

package yourpackagehere;

import javax.swing.*;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileLock;
import java.nio.file.*;




/**
 * SingleInstanceChecker v[(2), 2016-04-22 08:00 UTC] by dreamspace-president.com
 * <p>
 * (file lock single instance solution by Robert https://stackoverflow.com/a/2002948/3500521)
 */
public enum SingleInstanceChecker {

    INSTANCE; // HAHA! The CONFUSION!


    final public static int POLLINTERVAL = 1000;
    final public static File LOCKFILE = new File("SINGLE_INSTANCE_LOCKFILE");
    final public static File DETECTFILE = new File("EXTRA_INSTANCE_DETECTFILE");


    private boolean hasBeenUsedAlready = false;


    private WatchService watchService = null;
    private RandomAccessFile randomAccessFileForLock = null;
    private FileLock fileLock = null;


    /**
     * CAN ONLY BE CALLED ONCE.
     * <p>
     * Assumes that the program will close if FALSE is returned: The other-instance-tries-to-launch listener is not
     * installed in that case.
     * <p>
     * Checks if another instance is already running (temp file lock / shutdownhook). Depending on the accessibility of
     * the temp file the return value will be true or false. This approach even works even if the virtual machine
     * process gets killed. On the next run, the program can even detect if it has shut down irregularly, because then
     * the file will still exist. (Thanks to Robert https://stackoverflow.com/a/2002948/3500521 for that solution!)
     * <p>
     * Additionally, the method checks if another instance tries to start. In a crappy way, because as awesome as Java
     * is, it lacks some fundamental features. Don't worry, it has only been 25 years, it'll sure come eventually.
     *
     * @param codeToRunIfOtherInstanceTriesToStart Can be null. If not null and another instance tries to start (which
     *                                             changes the detect-file), the code will be executed. Could be used to
     *                                             bring the current (=old=only) instance to front. If null, then the
     *                                             watcher will not be installed at all, nor will the trigger file be
     *                                             created. (Null means that you just don't want to make use of this
     *                                             half of the class' purpose, but then you would be better advised to
     *                                             just use the 24 line method by Robert.)
     *                                             <p>
     *                                             BE CAREFUL with the code: It will potentially be called until the
     *                                             very last moment of the program's existence, so if you e.g. have a
     *                                             shutdown procedure or a window that would be brought to front, check
     *                                             if the procedure has not been triggered yet or if the window still
     *                                             exists / hasn't been disposed of yet. Or edit this class to be more
     *                                             comfortable. This would e.g. allow you to remove some crappy
     *                                             comments. Attribution would be nice, though.
     * @param executeOnAWTEventDispatchThread      Convenience function. If false, the code will just be executed. If
     *                                             true, it will be detected if we're currently on that thread. If so,
     *                                             the code will just be executed. If not so, the code will be run via
     *                                             SwingUtilities.invokeLater().
     * @return if this is the only instance
     */
    public boolean isOnlyInstance(final Runnable codeToRunIfOtherInstanceTriesToStart, final boolean executeOnAWTEventDispatchThread) {

        if (hasBeenUsedAlready) {
            throw new IllegalStateException("This class/method can only be used once, which kinda makes sense if you think about it.");
        }
        hasBeenUsedAlready = true;

        final boolean ret = canLockFileBeCreatedAndLocked();

        if (codeToRunIfOtherInstanceTriesToStart != null) {
            if (ret) {
                // Only if this is the only instance, it makes sense to install a watcher for additional instances.
                installOtherInstanceLaunchAttemptWatcher(codeToRunIfOtherInstanceTriesToStart, executeOnAWTEventDispatchThread);
            } else {
                // Only if this is NOT the only instance, it makes sense to create&delete the trigger file that will effect notification of the other instance.
                //
                // Regarding "codeToRunIfOtherInstanceTriesToStart != null":
                // While creation/deletion of the file concerns THE OTHER instance of the program,
                // making it dependent on the call made in THIS instance makes sense
                // because the code executed is probably the same.
                createAndDeleteOtherInstanceWatcherTriggerFile();
            }
        }

        optionallyInstallShutdownHookThatCleansEverythingUp();

        return ret;
    }


    private void createAndDeleteOtherInstanceWatcherTriggerFile() {

        try {
            final RandomAccessFile randomAccessFileForDetection = new RandomAccessFile(DETECTFILE, "rw");
            randomAccessFileForDetection.close();
            Files.deleteIfExists(DETECTFILE.toPath()); // File is created and then instantly deleted. Not a problem for the WatchService :)
        } catch (Exception e) {
            e.printStackTrace();
        }
    }


    private boolean canLockFileBeCreatedAndLocked() {

        try {
            randomAccessFileForLock = new RandomAccessFile(LOCKFILE, "rw");
            fileLock = randomAccessFileForLock.getChannel().tryLock();
            return fileLock != null;
        } catch (Exception e) {
            return false;
        }
    }


    private void installOtherInstanceLaunchAttemptWatcher(final Runnable codeToRunIfOtherInstanceTriesToStart, final boolean executeOnAWTEventDispatchThread) {

        // PREPARE WATCHSERVICE AND STUFF
        try {
            watchService = FileSystems.getDefault().newWatchService();
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }
        final File appFolder = new File("").getAbsoluteFile(); // points to current folder
        final Path appFolderWatchable = appFolder.toPath();


        // REGISTER CURRENT FOLDER FOR WATCHING FOR FILE DELETIONS
        try {
            appFolderWatchable.register(watchService, StandardWatchEventKinds.ENTRY_DELETE);
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }


        // INSTALL WATCHER THAT LOOKS IF OUR detectFile SHOWS UP IN THE DIRECTORY CHANGES. IF THERE'S A CHANGE, ANOTHER INSTANCE TRIED TO START, SO NOTIFY THE CURRENT ONE OF THAT.
        final Thread t = new Thread(() -> watchForDirectoryChangesOnExtraThread(codeToRunIfOtherInstanceTriesToStart, executeOnAWTEventDispatchThread));
        t.setDaemon(true);
        t.setName("directory content change watcher");
        t.start();
    }


    private void optionallyInstallShutdownHookThatCleansEverythingUp() {

        if (fileLock == null && randomAccessFileForLock == null && watchService == null) {
            return;
        }

        final Thread shutdownHookThread = new Thread(() -> {
            try {
                if (fileLock != null) {
                    fileLock.release();
                }
                if (randomAccessFileForLock != null) {
                    randomAccessFileForLock.close();
                }
                Files.deleteIfExists(LOCKFILE.toPath());
            } catch (Exception ignore) {
            }
            if (watchService != null) {
                try {
                    watchService.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        });
        Runtime.getRuntime().addShutdownHook(shutdownHookThread);
    }


    private void watchForDirectoryChangesOnExtraThread(final Runnable codeToRunIfOtherInstanceTriesToStart, final boolean executeOnAWTEventDispatchThread) {

        while (true) { // To eternity and beyond! Until the universe shuts down. (Should be a volatile boolean, but this class only has absolutely required features.)

            try {
                Thread.sleep(POLLINTERVAL);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }


            final WatchKey wk;
            try {
                wk = watchService.poll();
            } catch (ClosedWatchServiceException e) {
                // This situation would be normal if the watcher has been closed, but our application never does that.
                e.printStackTrace();
                return;
            }

            if (wk == null || !wk.isValid()) {
                continue;
            }


            for (WatchEvent<?> we : wk.pollEvents()) {

                final WatchEvent.Kind<?> kind = we.kind();
                if (kind == StandardWatchEventKinds.OVERFLOW) {
                    System.err.println("OVERFLOW of directory change events!");
                    continue;
                }


                final WatchEvent<Path> watchEvent = (WatchEvent<Path>) we;
                final File file = watchEvent.context().toFile();


                if (file.equals(DETECTFILE)) {

                    if (!executeOnAWTEventDispatchThread || SwingUtilities.isEventDispatchThread()) {
                        codeToRunIfOtherInstanceTriesToStart.run();
                    } else {
                        SwingUtilities.invokeLater(codeToRunIfOtherInstanceTriesToStart);
                    }

                    break;

                } else {
                    System.err.println("THIS IS THE FILE THAT WAS DELETED: " + file);
                }

            }

            wk.reset();
        }
    }

}
Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top