Как случайным образом взаимодействовать с процессом, не замораживая графический интерфейс в Java Swing?

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

Он также должен иметь функции, связанные с взаимодействием с шахматным движком (например, stockfish). Это то, с чем я сейчас борюсь. Шахматный движок представляет собой исполняемый файл, доступ к которому осуществляется с помощью ProcessBuilder:

Process chessEngineProcess = new ProcessBuilder(chessEngineUrl).start();

InputStream processInputStream = chessEngineProcess.getInputStream();
OutputStream processOutputStream = chessEngineProcess.getOutputStream();

BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(processOutputStream));
BufferedReader reader = new BufferedReader(new InputStreamReader(processInputStream));

Я хочу отправить строки (команды в протоколе UCI) движку, на который он отвечает, непрерывно выводя текст в течение нескольких секунд или дольше. Это зависает в графическом интерфейсе. Мне нужно обновить textArea (в режиме реального времени) в графическом интерфейсе на основе вывода движка. Это не будет разовая операция. Я хотел бы делать это случайным образом (отправлять команду и обновлять графический интерфейс в режиме реального времени) всякий раз, когда происходят определенные события графического интерфейса (например, пользователь делает ход).

Я знаю, что мне нужно выполнять чтение потока в другом потоке, и я знаю о SwingWorker, но я просто не могу заставить его работать должным образом.

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

Имея это в виду, я попытался создать класс, который расширяет SwingWorker<Void, String> и устанавливает и содержит chessEngineProcess (а также его потоковое чтение и запись) в качестве закрытой переменной-члена. Я реализовал методы doInBackground и process. У меня также был публичный метод в этом классе для отправки команды движку.

public void sendCommandToEngine(String command) {
        try {
            writer.write(command + '\n');
            writer.flush();
        } catch (IOException e) {
            JOptionPane.showMessageDialog(null, e.getMessage());
        }
    }

Я выполняю потоковое чтение в методе doInBackground, а затем публикую вывод и обновляю графический интерфейс в методе process.

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

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

EDIT: я получаю исключение нулевого указателя со следующей трассировкой стека:

Exception in thread "AWT-EventQueue-0" java.lang.NullPointerException
    at Moves.Move.isMovePossible(Move.java:84)
    at Moves.Move.executeMove(Move.java:68)
    at gui.ChessBoard.performEngineMove(ChessBoard.java:328)
    at gui.MainFrame.receiveEnginesBestMove(MainFrame.java:180)
    at gui.EngineWorker.process(EngineWorker.java:91)
    at javax.swing.SwingWorker$3.run(SwingWorker.java:414)
    at sun.swing.AccumulativeRunnable.run(AccumulativeRunnable.java:112)
    at javax.swing.SwingWorker$DoSubmitAccumulativeRunnable.run(SwingWorker.java:832)
    at sun.swing.AccumulativeRunnable.run(AccumulativeRunnable.java:112)
    at javax.swing.SwingWorker$DoSubmitAccumulativeRunnable.actionPerformed(SwingWorker.java:842)
    at javax.swing.Timer.fireActionPerformed(Timer.java:313)
    at javax.swing.Timer$DoPostEvent.run(Timer.java:245)
    at java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:311)
    at java.awt.EventQueue.dispatchEventImpl(EventQueue.java:756)
    at java.awt.EventQueue.access$500(EventQueue.java:97)
    at java.awt.EventQueue$3.run(EventQueue.java:709)
    at java.awt.EventQueue$3.run(EventQueue.java:703)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:80)
    at java.awt.EventQueue.dispatchEvent(EventQueue.java:726)
    at java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:201)
    at java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:116)
    at java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:105)
    at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
    at java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:93)
    at java.awt.EventDispatchThread.run(EventDispatchThread.java:82)

Некоторые подробности: в основном у меня есть класс «MainFrame», который представляет собой JFrame, который содержит все мои элементы графического интерфейса. Здесь я добавляю прослушиватели событий к своим компонентам. В определенных прослушивателях событий я вызываю sendCommandToEngine. Это запустит заблокированный doInBackground, когда движок начнет отправлять ответы.

Затем метод process может вызвать performEnginesMove для chessBoard (который является компонентом MainFrame, отображающим шахматную доску), если он обнаружит, что движок вывел «лучший ход».

Функция performEnginesMove проверяет правильность (возможность) хода и затем делает ход на доске (с помощью класса Move).

По какой-то причине это работает неправильно.


person Stanislav Dimitri    schedule 08.03.2020    source источник
comment
Если поток никогда не завершается, я бы просто создал обычный поток, который блокируется при чтении. Когда вы получите результат, вызовите invokeLater() для обновления графического интерфейса.   -  person Olivier    schedule 08.03.2020
comment
Ваше исключение, похоже, не связано с общением с процессом. Это в вашем собственном коде. Вы должны отлаживать isMovePossible().   -  person Olivier    schedule 08.03.2020
comment
Это определенно не isMovePossible(). Как я уже сказал, мощность двигателя неправильная, поэтому движение невозможно. К сожалению, это как-то связано с многопоточностью.   -  person Stanislav Dimitri    schedule 08.03.2020
comment
Вы пробовали вручную проверить, что возвращает процесс?   -  person Olivier    schedule 08.03.2020
comment
Вы имеете в виду из буферизованного ридера? Если так, то да, я проверял. Вывод неоднозначный - содержит как неправильные ходы (которые были сыграны ранее), так и иногда правильные ходы. Я не очень разбираюсь в многопоточности, поэтому я, вероятно, что-то напутал с этим.   -  person Stanislav Dimitri    schedule 08.03.2020
comment
Я имел в виду из командной строки: engine.exe < input.txt > output.txt   -  person Olivier    schedule 08.03.2020
comment
Это определенно не isMovePossible(). — трассировка стека говорит вам, что где-то в этом методе есть переменная null. Только вы знаете, какие переменные используются в указанном номере оператора.   -  person camickr    schedule 09.03.2020
comment
Итак, вы говорите, что с моим SwingWorker и моей настройкой все в порядке? Кстати, движок писал не я (это всем известный шахматный движок Stockfish). Это точно работает. Его не нужно тестировать. isMovePossible не является проблемой. Я знаю это, потому что если я просто выведу на консоль то, что метод doInBackground получает из потока, я увижу, что это неправильно. Это очень странно. Не стесняйтесь просить разъяснений по всему, что не ясно, и спасибо за вашу помощь!   -  person Stanislav Dimitri    schedule 09.03.2020
comment
Вы должны написать простой тест (вообще без графического интерфейса), чтобы увидеть, можете ли вы правильно взаимодействовать с движком.   -  person Olivier    schedule 09.03.2020
comment
используйте отдельные потоки для потоков каждого процесса: stdin, stderr и stdout.   -  person Alexei Kaigorodov    schedule 09.03.2020


Ответы (1)


Я создал делегат для классов Process и ProcessBuilder, чтобы показать, как следует использовать остальную часть кода. Я называю эти классы GameEngineProcess и GameEngineProcessBuilder соответственно.

GameEngineProcess создает ответы, которые представляют собой простые String, которые добавляются непосредственно в JTextArea графического интерфейса игрока. Фактически он расширяет Thread, позволяя ему работать асинхронно. Таким образом, реализация этого конкретного класса - это не то, о чем вы просите, но она используется для имитации класса Process. Я добавил некоторую задержку в ответах этого класса, чтобы имитировать время, необходимое движку для их генерации.

Затем есть пользовательский класс OnUserActionWorker, который расширяет SwingWorker и асинхронно выполняет то, о чем вы просите: он получает ответы от процесса движка и перенаправляет их в графический интерфейс, который обновляет свой JTextArea. Этот класс используется один раз для запроса движка, т.е. мы создаем и выполняем новый экземпляр этого класса для каждого запроса, создаваемого пользователем при взаимодействии с графическим интерфейсом. Обратите внимание, что это не означает, что механизм закрывается и снова открывается для каждого запроса. GameEngineProcess запускается один раз, а затем продолжает работать в течение всего времени безотказной работы игры.

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

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

Следует коду:

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GridLayout;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.List;
import java.util.Objects;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;

public class Main {

    //Just a simple 'flag' to indicate end of responses per engine request:
    private static final String END_OF_MESSAGES = "\u0000\u0000\u0000\u0000";

    //A class simulating the 'ProcessBuilder' class:
    private static class GameEngineProcessBuilder {
        private String executionCommand;

        public GameEngineProcessBuilder(final String executionCommand) {
            this.executionCommand = executionCommand;
        }

        public GameEngineProcessBuilder command(final String executionCommand) {
            this.executionCommand = executionCommand;
            return this;
        }

        public GameEngineProcess start() throws IOException {
            final GameEngineProcess gep = new GameEngineProcess(executionCommand);
            gep.setDaemon(true);
            gep.start();
            return gep;
        }
    }

    //A class simulating the 'Process' class:
    private static class GameEngineProcess extends Thread {
        private final String executionCommand; //Actually not used.
        private final PipedInputStream stdin, clientStdin;
        private final PipedOutputStream stdout, clientStdout;

        public GameEngineProcess(final String executionCommand) throws IOException {
            this.executionCommand = Objects.toString(executionCommand); //Assuming nulls allowed.

            //Client side streams:
            clientStdout = new PipedOutputStream();
            clientStdin = new PipedInputStream();

            //Remote streams (of the engine):
            stdin = new PipedInputStream(clientStdout);
            stdout = new PipedOutputStream(clientStdin);
        }

        public OutputStream getOutputStream() {
            return clientStdout;
        }

        public InputStream getInputStream() {
            return clientStdin;
        }

        @Override
        public void run() {
            try {
                final BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(stdout));
                final BufferedReader br = new BufferedReader(new InputStreamReader(stdin));
                String line = br.readLine();
                while (line != null) {
                    for (int i = 0; i < 10; ++i) { //Simulate many responses per request.
                        Thread.sleep(333); //Simulate a delay in the responses.
                        bw.write(line + " (" + i + ')'); //Echo the line with the index.
                        bw.newLine();
                        bw.flush();
                    }
                    bw.write(END_OF_MESSAGES); //Indicate termination of this particular request.
                    bw.newLine();
                    bw.flush();
                    line = br.readLine();
                }
                System.out.println("Process gracefull shutdown.");
            }
            catch (final InterruptedException | IOException x) {
                System.err.println("Process termination with error: " + x);
            }
        }
    }

    //This is the SwingWorker that handles the responses from the engine and updates the GUI.
    private static class OnUserActionWorker extends SwingWorker<Void, String> {
        private final GameFrame gui;
        private final String commandToEngine;

        private OnUserActionWorker(final GameFrame gui,
                                   final String commandToEngine) {
            this.gui = Objects.requireNonNull(gui);
            this.commandToEngine = Objects.toString(commandToEngine); //Assuming nulls allowed.
        }

        //Not on the EDT...
        @Override
        protected Void doInBackground() throws Exception {
            final BufferedWriter bw = gui.getEngineProcessWriter();
            final BufferedReader br = gui.getEngineProcessReader();

            //Send request:
            bw.write(commandToEngine);
            bw.newLine();
            bw.flush();

            //Receive responses:
            String line = br.readLine();
            while (line != null && !line.equals(END_OF_MESSAGES)) {
                publish(line); //Use 'publish' to forward the text to the 'process' method.
                line = br.readLine();
            }

            return null;
        }

        //On the EDT...
        @Override
        protected void done() {
            gui.responseDone(); //Indicate end of responses at the GUI level.
        }

        //On the EDT...
        @Override
        protected void process(final List<String> chunks) {
            chunks.forEach(chunk -> gui.responsePart(chunk)); //Sets the text of the the text area of the GUI.
        }
    }

    //The main frame of the GUI of the user/player:
    private static class GameFrame extends JFrame implements Runnable {
        private final JButton[][] grid;
        private final JTextArea output;
        private BufferedReader procReader;
        private BufferedWriter procWriter;

        public GameFrame(final int rows,
                         final int cols) {
            super("Chess with remote engine");

            output = new JTextArea(rows, cols);
            output.setEditable(false);
            output.setFont(new Font(Font.MONOSPACED, Font.ITALIC, output.getFont().getSize()));

            final JPanel gridPanel = new JPanel(new GridLayout(0, cols));

            grid = new JButton[rows][cols];
            for (int row = 0; row < rows; ++row)
                for (int col = 0; col < cols; ++col) {
                    final JButton b = new JButton(String.format("Chessman %02d,%02d", row, col));
                    b.setPreferredSize(new Dimension(b.getPreferredSize().width, 50));
                    b.addActionListener(e -> sendCommandToEngine("Click \"" + b.getText() + "\"!"));
                    gridPanel.add(b);
                    grid[row][col] = b;
                }

            final JScrollPane outputScroll = new JScrollPane(output);
            outputScroll.setPreferredSize(gridPanel.getPreferredSize());

            final JPanel contents = new JPanel(new BorderLayout());
            contents.add(gridPanel, BorderLayout.LINE_START);
            contents.add(outputScroll, BorderLayout.CENTER);

            super.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            super.getContentPane().add(contents);
            super.pack();
        }

        //Utility method to enable/disable all the buttons of the grid at once:
        private void gridSetEnabled(final boolean enabled) {
            for (final JButton[] row: grid)
                for (final JButton b: row)
                    b.setEnabled(enabled);
        }

        //This is the method that sends the next request to the engine:
        private void sendCommandToEngine(final String commandToEngine) {
            gridSetEnabled(false);
            output.setText("> Command accepted.");
            new OnUserActionWorker(this, commandToEngine).execute();
        }

        public BufferedReader getEngineProcessReader() {
            return procReader;
        }

        public BufferedWriter getEngineProcessWriter() {
            return procWriter;
        }

        //Called by 'SwingWorker.process':
        public void responsePart(final String msg) {
            output.append("\n" + msg);
        }

        //Called by 'SwingWorker.done':
        public void responseDone() {
            output.append("\n> Response finished.");
            gridSetEnabled(true);
        }

        @Override
        public void run() {
            try {
                //Here you build and start the process:
                final GameEngineProcess proc = new GameEngineProcessBuilder("stockfish").start();

                //Here you obtain the I/O streams:
                procWriter = new BufferedWriter(new OutputStreamWriter(proc.getOutputStream()));
                procReader = new BufferedReader(new InputStreamReader(proc.getInputStream()));

                //Finally show the GUI:
                setLocationRelativeTo(null);
                setVisible(true);
            }
            catch (final IOException iox) {
                JOptionPane.showMessageDialog(null, iox.toString());
            }
        }
    }

    public static void main(final String[] args) {
        new GameFrame(3, 3).run(); //The main thread starts the game, which shows the GUI...
    }
}

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

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

Чтобы проверить реакцию EDT во время получения ответов, вы можете, например, просто изменить размер окна с помощью мыши, пока все еще принимаются (десять) ответов, или просто заметить, что ответы печатаются в JTextArea в реальном времени.

Надеюсь, поможет.

person gthanop    schedule 17.03.2020