javamavengoogle-app-engineweb.xmlgoogle-cloud-sdk

How do I update an App Engine project to Java 11 without web.xml?


I have an App Engine project. Here is a sample repo, but it only contains a few files:

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.happycoding</groupId>
  <artifactId>google-cloud-hello-world</artifactId>
  <version>1</version>
  <packaging>war</packaging>

  <properties>
    <!-- App Engine currently supports Java 8 -->
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <failOnMissingWebXml>false</failOnMissingWebXml>
  </properties>

  <dependencies>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>4.0.1</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>com.google.appengine</groupId>
        <artifactId>appengine-maven-plugin</artifactId>
        <version>1.9.71</version>
      </plugin>
    </plugins>
  </build>
</project>

appengine-web.xml

<?xml version="1.0" encoding="utf-8"?>
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
  <application>MY_PROJECT_ID_HERE</application>
  <version>1</version>
  <threadsafe>false</threadsafe>
  <sessions-enabled>true</sessions-enabled>
  <runtime>java8</runtime>
</appengine-web-app>

HelloWorldServlet.java

package io.happycoding.servlets;

import java.io.IOException;

import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/hello")
public class HelloWorldServlet extends HttpServlet {

  @Override
  public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
    response.setContentType("text/html;");
    response.getOutputStream().println("<h1>Hello world!</h1>");
  }
}

I do not have a web.xml file because I'm using the @WebServlet annotation instead. This has worked perfectly for years.

The only problem was that I was restricted to using Java 8, so I was happy to see App Engine announce support for Java 11. I am now trying to upgrade my App Engine project to Java 11.

I started by changing the appengine-web.xml file to contain this line:

<runtime>java11</runtime>

I also changed the pom.xml file:

<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>

I run mvn appengine:devserver (which works fine before this change), and I get this error:

ClassLoader is jdk.internal.loader.ClassLoaders$AppClassLoader@78308db1, not a URLClassLoader.

I gather that this is because the App Engine Maven plugin itself requires Java 8. I also learn that the App Engine Maven plugin is deprecated, and that I should upgrade to the Cloud SDK Maven plugin. Okay fine.

I follow this guide and I change the plugin in my pom.xml file to this:

<plugin>
   <groupId>com.google.cloud.tools</groupId>
   <artifactId>appengine-maven-plugin</artifactId>
   <version>2.2.0</version>
</plugin>

I then run mvn package appengine:run (because of course the command to run a devserver changed too), but now I get this error:

 java.io.FileNotFoundException: /home/kevin/gcloud-tutorials/hello-world/target/hello-world-1/WEB-INF/web.xml (No such file or directory)

The error says it can't find a web.xml file, but I shouldn't need one because I'm using the @WebServlet annotation! My pom.xml file also contains a <failOnMissingWebXml>false</failOnMissingWebXml> property, but I don't know whether that does anything with the new plugin.

Am I missing a step or property? How do I upgrade my App Engine project to use Java 11, without also requiring a web.xml file?


Solution

  • There are some pretty huge differences between App Engine's Java 8 runtime and its Java 11 runtime.

    Specifically, the Java 8 runtime included a Jetty web server that automatically ran your code, but the Java 11 runtime no longer includes this, so you have to include it yourself.

    There is a migration guide here but I found that very confusing to follow, so I'll try to outline the steps here:

    Step 1: Migrate from appengine-web.xml to app.yaml.

    Delete your appengine-web.xml file, and create a new app.yaml file. My app.yaml file only contained a single line:

    runtime: java11
    

    Step 2: Add a main entry point class that runs a web server.

    There are many ways to do this, but there's what I did:

    package io.happycoding;
    
    import java.net.URL;
    import org.eclipse.jetty.annotations.AnnotationConfiguration;
    import org.eclipse.jetty.server.Handler;
    import org.eclipse.jetty.server.handler.DefaultHandler;
    import org.eclipse.jetty.server.Server;
    import org.eclipse.jetty.servlet.DefaultServlet;
    import org.eclipse.jetty.webapp.Configuration;
    import org.eclipse.jetty.webapp.WebAppContext;
    import org.eclipse.jetty.webapp.WebInfConfiguration;
    
    /**
     * Starts up the server, including a DefaultServlet that handles static files,
     * and any servlet classes annotated with the @WebServlet annotation.
     */
    public class ServerMain {
    
      public static void main(String[] args) throws Exception {
    
        // Create a server that listens on port 8080.
        Server server = new Server(8080);
        WebAppContext webAppContext = new WebAppContext();
        server.setHandler(webAppContext);
    
        // Load static content from inside the jar file.
        URL webAppDir =
            ServerMain.class.getClassLoader().getResource("META-INF/resources");
        webAppContext.setResourceBase(webAppDir.toURI().toString());
    
        // Enable annotations so the server sees classes annotated with @WebServlet.
        webAppContext.setConfigurations(new Configuration[]{ 
          new AnnotationConfiguration(),
          new WebInfConfiguration(), 
        });
    
        // Look for annotations in the classes directory (dev server) and in the
        // jar file (live server)
        webAppContext.setAttribute(
            "org.eclipse.jetty.server.webapp.ContainerIncludeJarPattern", 
            ".*/target/classes/|.*\\.jar");
    
        // Handle static resources, e.g. html files.
        webAppContext.addServlet(DefaultServlet.class, "/");
    
        // Start the server! 🚀
        server.start();
        System.out.println("Server started!");
    
        // Keep the main thread alive while the server is running.
        server.join();
      }
    }
    

    This class uses Jetty to run a web server, and then adds the rest of your code to that web server. This code assumes that everything will be packaged in the same .jar file.

    Step 3: Modify pom.xml

    Your pom.xml needs a few things:

    1. Dependencies for the web server you're running. I used Jetty.
    2. Plugins for packaging your code. I chose to package mine as a single uber jar, so I used maven-resources-plugin and maven-shade-plugin.
    3. Plugins for running your code locally. The old appengine-maven-plugin does not work for deploying locally, because it still requires appengine-web.xml for some reason. Because I chose the uber jar approach, I used exec-maven-plugin.
    4. The appengine-maven-plugin does still work for deploying to the live server, so you still need it for that.

    If that sounds confusing, you're right. But putting it all together, here's what I came up with:

    <project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
      <modelVersion>4.0.0</modelVersion>
    
      <groupId>io.happycoding</groupId>
      <artifactId>app-engine-hello-world</artifactId>
      <version>1</version>
    
      <properties>
        <!-- App Engine currently supports Java 11 -->
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jetty.version>9.4.31.v20200723</jetty.version>
    
        <!-- Project-specific properties -->
        <mainClass>io.happycoding.ServerMain</mainClass>
        <googleCloudProjectId>YOUR_PROJECT_ID_HERE</googleCloudProjectId>
      </properties>
    
      <dependencies>
        <!-- Java Servlets API -->
        <dependency>
          <groupId>javax.servlet</groupId>
          <artifactId>javax.servlet-api</artifactId>
          <version>4.0.1</version>
        </dependency>
    
        <!-- Jetty -->
        <dependency>
          <groupId>org.eclipse.jetty</groupId>
          <artifactId>jetty-server</artifactId>
          <version>${jetty.version}</version>
        </dependency>
        <dependency>
          <groupId>org.eclipse.jetty</groupId>
          <artifactId>jetty-annotations</artifactId>
          <version>${jetty.version}</version>
        </dependency>
      </dependencies>
    
      <build>
        <plugins>
          <!-- Copy static resources like html files into the output jar file. -->
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-resources-plugin</artifactId>
            <version>2.7</version>
            <executions>
              <execution>
                <id>copy-web-resources</id>
                <phase>compile</phase>
                <goals><goal>copy-resources</goal></goals>
                <configuration>
                  <outputDirectory>
                    ${project.build.directory}/classes/META-INF/resources
                  </outputDirectory>
                  <resources>
                    <resource><directory>./src/main/webapp</directory></resource>
                  </resources>
                </configuration>
              </execution>
            </executions>
          </plugin>
    
          <!-- Package everything into a single executable jar file. -->
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.2.4</version>
            <executions>
              <execution>
                <phase>package</phase>
                <goals><goal>shade</goal></goals>
                <configuration>
                  <createDependencyReducedPom>false</createDependencyReducedPom>
                  <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                      <mainClass>${mainClass}</mainClass>
                    </transformer>
                  </transformers>
                </configuration>
              </execution>
            </executions>
          </plugin>
    
          <!-- Exec plugin for deploying the local server. -->
          <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>exec-maven-plugin</artifactId>
            <version>3.0.0</version>
            <executions>
              <execution>
                <goals>
                  <goal>java</goal>
                </goals>
              </execution>
            </executions>
            <configuration>
              <mainClass>${mainClass}</mainClass>
            </configuration>
          </plugin>
    
          <!-- App Engine plugin for deploying to the live site. -->
          <plugin>
            <groupId>com.google.cloud.tools</groupId>
            <artifactId>appengine-maven-plugin</artifactId>
            <version>2.2.0</version>
            <configuration>
              <projectId>${googleCloudProjectId}</projectId>
              <version>1</version>
            </configuration>
          </plugin>
        </plugins>
      </build>
    </project>
    

    I put all of this into an example project available here.