Implementing a JSON-RPC protocol with Eclipse LSP4J

Introduction

The Language Server protocol (LSP) is a communication protocol between tools and compilers. The LSP enables compilers to expose their interfaces into a language agnostic fashion via so-called “language servers” and allows tools to consume new languages by connecting to these servers.

Eclipse LSP4J supports the development and consumption of language servers in Java. More than that, it allows developers to define new JSON-RPC protocols, implement endpoints and exchange an underlying transport.

In this article, I will introduce you to core concepts of LSP4J with an example. We will define a new JSON-RPC protocol for a chat app, as well as implement a chat client and server talking over this protocol. We will use Java sockets for the transport layer and finally, we look at how these concepts can be applied to consume and provide a language server. You can find the source code of the example here.

Defining a Simple Chat JSON-RPC Protocol

JSON-RPC is a transport-independent remote procedure call protocol using JSON as a data format. A call is represented by sending request or notification messages from the client to the server. A new protocol is defined by declaring requests, notifications and types used by the messages. Below you can see definition of the simple chat protocol. It is defined using LSP4J service layer's annotations.

@JsonSegment("server")
public interface ChatServer {
    
    /**
     * The `server/fetchMessage` request is sent by the client to fetch messages posted so far.
     */
    @JsonRequest
    CompletableFuture<List<UserMessage>> fetchMessages();
    
    /**
     * The `server/postMessage` notification is sent by the client to post a new message.
     * The server should store a message and broadcast it to all clients.
     */
    @JsonNotification
    void postMessage(UserMessage message);

}

@JsonSegment("client")
public interface ChatClient {
    
    /**
     * The `client/didPostMessage` is sent by the server to all clients 
     * in a response to the `server/postMessage` notification.
     */
    @JsonNotification
    void didPostMessage(UserMessage message);

}

public class UserMessage {

    /**
     * A user posted this message.
     */
    private String user;

    /**
     * A content of this message.
     */
    private String content;

}

Implementing Chat Client and Server Endpoints

LSP4J allows bi-directional communication, meaning that each side can receive and send requests and notifications. Each side exposes its interface by implementing the Endpoint interface.

Endpoint is a minimal interface to handle JSON-RPC messages with generic JSON elements. To provide type-safe and convenient interfaces, LSP4J uses typed service objects that reflectively delegate to the generic Endpoint API. A service interface is a plain Java interface that has methods annotated with @JsonRequest or @JsonNotification annotations.

Below you can find the two implementations of our service interfaces ChatServerImpl and ChatClientImpl.

public class ChatServerImpl implements ChatServer {
    
    private final List<UserMessage> messages = new CopyOnWriteArrayList<>();
    private final List<ChatClient> clients = new CopyOnWriteArrayList<>();

    /**
     * Return existing messages.
     */
    @Override
    public CompletableFuture<List<UserMessage>> fetchMessages() {
        return CompletableFuture.completedFuture(messages);
    }

    /**
     * Store the message posted by the chat client
     * and broadcast it to all clients.
     */
    @Override
    public void postMessage(UserMessage message) {
        messages.add(message);
        for (ChatClient client : clients) {
            client.didPostMessage(message);
        }
    }

    /**
     * Connect the given chat client.
     * Return a runnable which should be executed to disconnect the client.
     */
    public Runnable addClient(ChatClient client) {
      this.clients.add(client);
      return () -> this.clients.remove(client);
    }

}
public class ChatClientImpl implements ChatClient {
    
    private final Scanner scanner = new Scanner(System.in);
    
    /**
     * 1. Ask the user for a name
     * 2. Fetch existing messages from the remote server and display them
     * 3. Ask the user for a next message
     * 4. Post a new message to the chat server, continue with step 3
     */
    public void start(ChatServer server) throws Exception {
        System.out.print("Enter your name: ");
        String user = scanner.nextLine();
        server.fetchMessages().get().forEach(message -> this.didPostMessage(message));
        while (true) {
            String content = scanner.nextLine();
            server.postMessage(new UserMessage(user, content));
        }
    }

    /**
     * Display the posted message.
     */
    @Override
    public void didPostMessage(UserMessage message) {
        System.out.println(message.getUser() + ": " + message.getContent());
    }

}

As you can see in the code above, the server needs to be connected to any chat clients that want to participate. With this, we could already build the chat system by simply creating an instance of the server and configuring it with any number of client objects. But of course, we want the clients and server to communicate over a remote connection.

Launching the Chat Server

We want to start a chat server over Java sockets, so first, we have to create an instance of SocketServer and accept a new socket connection. Once we have the connection, we need to turn it into an instance of ChatClient and pass it to the chat server. The Launcher class provides means to

  • listen for incoming messages via startListening method;
  • test whether the connection is still opened based on the state of a Future returned by startListening;
  • and most importantly access a remote proxy of a desirable JSON-RPC interface, in our case ChatClient.
public class ChatServerLauncher {

    public static void main(String[] args) throws Exception {
        // create the chat server
        ChatServerImpl chatServer = new ChatServerImpl();
        ExecutorService threadPool = Executors.newCachedThreadPool();

        Integer port = Integer.valueOf(args[0]);
        // start the socket server
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("The chat server is running on port " + port);
            threadPool.submit(() -> {
                while (true) {
                    // wait for clients to connect
                    Socket socket = serverSocket.accept();
                    // create a JSON-RPC connection for the accepted socket
                    SocketLauncher<ChatClient> launcher = new SocketLauncher<>(chatServer, ChatClient.class, socket);
                    // connect a remote chat client proxy to the chat server
                    Runnable removeClient = chatServer.addClient(launcher.getRemoteProxy());
                    /*
                     * Start listening for incoming messages.
                     * When the JSON-RPC connection is closed
                     * disconnect the remote client from the chat server.
                     */
                    launcher.startListening().thenRun(removeClient);
                }
            });
            System.out.println("Enter any character to stop");
            System.in.read();
            System.exit(0);
        }
    }

}

Connecting to the Chat Server

For the chat client, we have to establish a socket connection and then use Launcher to get a remote proxy for ChatServer that is used to start the chat session.

public class ChatClientLauncher {

    public static void main(String[] args) throws Exception {
        // create the chat client
        ChatClientImpl chatClient = new ChatClientImpl();

        String host = args[0];
        Integer port = Integer.valueOf(args[1]);
        // connect to the server
        try (Socket socket = new Socket(host, port)) {
            // open a JSON-RPC connection for the opened socket
            SocketLauncher<ChatServer> launcher = new SocketLauncher<>(chatClient, ChatServer.class, socket);
            /*
             * Start listening for incoming message.
             * When the JSON-RPC connection is closed, 
             * e.g. the server is died, 
             * the client process should exit.
             */
            launcher.startListening().thenRun(() -> System.exit(0));
            // start the chat session with a remote chat server proxy
            chatClient.start(launcher.getRemoteProxy());
        }
    }

}

How is LSP4J then specific to the Language Server Protocol?

The LSP part of LSP4J is actually a separated module and uses the very same mechanism:

  • LanguageServer interface should be implemented to provide a language server, similar to ChatServerImpl;
  • LanguageClient interface should be implemented to provide a language client, similar to ChatClientImpl;
  • LSPLauncher should be used to start a language server or connect a language client to an existing language server as shown below.
MyLanguageServer server = new MyLanguageServer();
Launcher<LanguageClient> launcher = LSPLauncher.createServerLauncher(server, input, output);
server.setClient(launcher.getRemoteProxy());
launcher.startListening();
MyLanguageClient client = new MyLanguageClient();
Launcher<LanguageServer> launcher = LSPLauncher.createClientLauncher(client, input, output);
client.setServer(launcher.getRemoteProxy());
launcher.startListening();

Conclusion

Although LSP4J carries the LSP bit in its name, it is, in fact, a generic and very convenient JSON-RPC implementation for Java, that can be used for all JSON-RPC protocols. The LSP specific part is nicely separated, and you don't need to deal with that when working with your own protocols.

About the Author

Anton Kosyakov

Anton Kosyakov
TypeFox