Ship initally required data in index.html
Gerrit's UI dispatches a number of requests to the server when it
initially loads. These are to obtain server configs, server version and
account data. These round trips add additional time when the App starts.
This commit ships the most relevant data in-line directly in index.html.
On the server, this is almost for free because when rendering
index.html, we already authenticated the user, so inline this data is
cheap. The server information is static, so it is also cheap to inline.
We expect this to help reduce the App's startup on slow networks.
We render the data using Gson and Soy. Soy has a predefined ordainer to
render JSON data. We use this to safely escape values.
Change-Id: I8e9cc077fa7212ca782b1ec334d41b872a3fd470
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index a414e84..622c9c2 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -14,12 +14,21 @@
package com.google.gerrit.httpd.raw;
+import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
import com.google.common.io.Resources;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gson.Gson;
import com.google.template.soy.SoyFileSet;
import com.google.template.soy.data.SanitizedContent;
import com.google.template.soy.data.SoyMapData;
@@ -29,37 +38,58 @@
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class IndexServlet extends HttpServlet {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final long serialVersionUID = 1L;
- protected final byte[] indexSource;
+
+ @Nullable private final String canonicalUrl;
+ @Nullable private final String cdnPath;
+ @Nullable private final String faviconPath;
+ private final GerritApi gerritApi;
+ private final SoyTofu soyTofu;
IndexServlet(
- @Nullable String canonicalURL, @Nullable String cdnPath, @Nullable String faviconPath)
- throws URISyntaxException {
- String resourcePath = "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
- SoyFileSet.Builder builder = SoyFileSet.builder();
- builder.add(Resources.getResource(resourcePath));
- SoyTofu.Renderer renderer =
- builder
+ @Nullable String canonicalUrl,
+ @Nullable String cdnPath,
+ @Nullable String faviconPath,
+ GerritApi gerritApi) {
+ this.canonicalUrl = canonicalUrl;
+ this.cdnPath = cdnPath;
+ this.faviconPath = faviconPath;
+ this.gerritApi = gerritApi;
+ this.soyTofu =
+ SoyFileSet.builder()
+ .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
.build()
- .compileToTofu()
- .newRenderer("com.google.gerrit.httpd.raw.Index")
- .setContentKind(SanitizedContent.ContentKind.HTML)
- .setData(getTemplateData(canonicalURL, cdnPath, faviconPath));
- indexSource = renderer.render().getBytes(UTF_8);
+ .compileToTofu();
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+ SoyTofu.Renderer renderer;
+ try {
+ SoyMapData templateData = getStaticTemplateData(canonicalUrl, cdnPath, faviconPath);
+ templateData.put("gerritInitialData", getInitialData());
+ renderer =
+ soyTofu
+ .newRenderer("com.google.gerrit.httpd.raw.Index")
+ .setContentKind(SanitizedContent.ContentKind.HTML)
+ .setData(templateData);
+ } catch (URISyntaxException | RestApiException e) {
+ throw new IOException(e);
+ }
+
rsp.setCharacterEncoding(UTF_8.name());
rsp.setContentType("text/html");
rsp.setStatus(SC_OK);
try (OutputStream w = rsp.getOutputStream()) {
- w.write(indexSource);
+ w.write(renderer.render().getBytes(UTF_8));
}
}
@@ -74,7 +104,7 @@
return uri.getPath().replaceAll("/$", "");
}
- static SoyMapData getTemplateData(String canonicalURL, String cdnPath, String faviconPath)
+ static SoyMapData getStaticTemplateData(String canonicalURL, String cdnPath, String faviconPath)
throws URISyntaxException {
String canonicalPath = computeCanonicalPath(canonicalURL);
@@ -96,4 +126,33 @@
"staticResourcePath", sanitizedStaticPath,
"faviconPath", faviconPath);
}
+
+ private Map<String, SanitizedContent> getInitialData() throws RestApiException {
+ Gson gson = OutputFormat.JSON_COMPACT.newGson();
+ Map<String, SanitizedContent> initialData = new HashMap<>();
+ Server serverApi = gerritApi.config().server();
+ initialData.put("\"/config/server/info\"", serializeObject(gson, serverApi.getInfo()));
+ initialData.put("\"/config/server/version\"", serializeObject(gson, serverApi.getVersion()));
+ initialData.put("\"/config/server/top-menus\"", serializeObject(gson, serverApi.topMenus()));
+
+ try {
+ AccountApi accountApi = gerritApi.accounts().self();
+ initialData.put("\"/accounts/self/detail\"", serializeObject(gson, accountApi.get()));
+ initialData.put(
+ "\"/accounts/self/preferences\"", serializeObject(gson, accountApi.getPreferences()));
+ initialData.put(
+ "\"/accounts/self/preferences.diff\"",
+ serializeObject(gson, accountApi.getDiffPreferences()));
+ initialData.put(
+ "\"/accounts/self/preferences.edit\"",
+ serializeObject(gson, accountApi.getEditPreferences()));
+ } catch (AuthException e) {
+ logger.atFine().withCause(e).log(
+ "Can't inline account-related data because user is unauthenticated");
+ // Don't render data
+ // TODO(hiesel): Tell the client that the user is not authenticated so that it doesn't have to
+ // fetch anyway. This requires more client side modifications.
+ }
+ return initialData;
+ }
}
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index cf21fcd..2b11015 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -22,6 +22,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.httpd.XsrfCookieFilter;
import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
import com.google.gerrit.launcher.GerritLauncher;
@@ -41,7 +42,6 @@
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
-import java.net.URISyntaxException;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import javax.servlet.Filter;
@@ -218,11 +218,12 @@
@Singleton
@Named(POLYGERRIT_INDEX_SERVLET)
HttpServlet getPolyGerritUiIndexServlet(
- @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg)
- throws URISyntaxException {
+ @CanonicalWebUrl @Nullable String canonicalUrl,
+ @GerritServerConfig Config cfg,
+ GerritApi gerritApi) {
String cdnPath = cfg.getString("gerrit", null, "cdnPath");
String faviconPath = cfg.getString("gerrit", null, "faviconPath");
- return new IndexServlet(canonicalUrl, cdnPath, faviconPath);
+ return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi);
}
@Provides
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 307a23e..22751bb 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -15,53 +15,52 @@
package com.google.gerrit.httpd.raw;
import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.easymock.EasyMock.verify;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.api.config.Config;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
import com.google.template.soy.data.SoyMapData;
-import java.net.URISyntaxException;
import org.junit.Test;
public class IndexServletTest {
- static class TestIndexServlet extends IndexServlet {
- private static final long serialVersionUID = 1L;
-
- TestIndexServlet(String canonicalURL, String cdnPath, String faviconPath)
- throws URISyntaxException {
- super(canonicalURL, cdnPath, faviconPath);
- }
-
- String getIndexSource() {
- return new String(indexSource, UTF_8);
- }
- }
-
@Test
- public void noPathAndNoCDN() throws URISyntaxException {
- SoyMapData data = IndexServlet.getTemplateData("http://example.com/", null, null);
+ public void noPathAndNoCDN() throws Exception {
+ SoyMapData data = IndexServlet.getStaticTemplateData("http://example.com/", null, null);
assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("");
}
@Test
- public void pathAndNoCDN() throws URISyntaxException {
- SoyMapData data = IndexServlet.getTemplateData("http://example.com/gerrit/", null, null);
+ public void pathAndNoCDN() throws Exception {
+ SoyMapData data = IndexServlet.getStaticTemplateData("http://example.com/gerrit/", null, null);
assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
assertThat(data.getSingle("staticResourcePath").stringValue()).isEqualTo("/gerrit");
}
@Test
- public void noPathAndCDN() throws URISyntaxException {
+ public void noPathAndCDN() throws Exception {
SoyMapData data =
- IndexServlet.getTemplateData("http://example.com/", "http://my-cdn.com/foo/bar/", null);
+ IndexServlet.getStaticTemplateData(
+ "http://example.com/", "http://my-cdn.com/foo/bar/", null);
assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("");
assertThat(data.getSingle("staticResourcePath").stringValue())
.isEqualTo("http://my-cdn.com/foo/bar/");
}
@Test
- public void pathAndCDN() throws URISyntaxException {
+ public void pathAndCDN() throws Exception {
SoyMapData data =
- IndexServlet.getTemplateData(
+ IndexServlet.getStaticTemplateData(
"http://example.com/gerrit", "http://my-cdn.com/foo/bar/", null);
assertThat(data.getSingle("canonicalPath").stringValue()).isEqualTo("/gerrit");
assertThat(data.getSingle("staticResourcePath").stringValue())
@@ -69,12 +68,45 @@
}
@Test
- public void renderTemplate() throws URISyntaxException {
+ public void renderTemplate() throws Exception {
+ Accounts accountsApi = createMock(Accounts.class);
+ expect(accountsApi.self()).andThrow(new AuthException("user needs to be authenticated"));
+
+ Server serverApi = createMock(Server.class);
+ expect(serverApi.getVersion()).andReturn("123");
+ expect(serverApi.topMenus()).andReturn(ImmutableList.of());
+ ServerInfo serverInfo = new ServerInfo();
+ serverInfo.defaultTheme = "my-default-theme";
+ expect(serverApi.getInfo()).andReturn(serverInfo);
+
+ Config configApi = createMock(Config.class);
+ expect(configApi.server()).andReturn(serverApi);
+
+ GerritApi gerritApi = createMock(GerritApi.class);
+ expect(gerritApi.accounts()).andReturn(accountsApi);
+ expect(gerritApi.config()).andReturn(configApi);
+
String testCanonicalUrl = "foo-url";
String testCdnPath = "bar-cdn";
String testFaviconURL = "zaz-url";
- TestIndexServlet servlet = new TestIndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL);
- String output = servlet.getIndexSource();
+ IndexServlet servlet =
+ new IndexServlet(testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi);
+
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ replay(gerritApi);
+ replay(configApi);
+ replay(serverApi);
+ replay(accountsApi);
+
+ servlet.doGet(new FakeHttpServletRequest(), response);
+
+ verify(gerritApi);
+ verify(configApi);
+ verify(serverApi);
+ verify(accountsApi);
+
+ String output = response.getActualBodyString();
assertThat(output).contains("<!DOCTYPE html>");
assertThat(output).contains("window.CANONICAL_PATH = '" + testCanonicalUrl);
assertThat(output).contains("<link rel=\"preload\" href=\"" + testCdnPath);
@@ -84,5 +116,12 @@
+ testCanonicalUrl
+ "/"
+ testFaviconURL);
+ assertThat(output)
+ .contains(
+ "window.INITIAL_DATA = JSON.parse("
+ + "'\\x7b\\x22\\/config\\/server\\/version\\x22: \\x22123\\x22, "
+ + "\\x22\\/config\\/server\\/info\\x22: \\x7b\\x22default_theme\\x22:"
+ + "\\x22my-default-theme\\x22\\x7d, \\x22\\/config\\/server\\/top-menus\\x22: "
+ + "\\x5b\\x5d\\x7d');</script>");
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index e69c8fc..48484ac 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -144,6 +144,14 @@
constructor() {
// Container of per-canonical-path caches.
this._data = new Map();
+ if (window.INITIAL_DATA != undefined) {
+ // Put all data shipped with index.html into the cache. This makes it
+ // so that we spare more round trips to the server when the app loads
+ // initially.
+ Object
+ .entries(window.INITIAL_DATA)
+ .forEach(e => this._cache().set(e[0], e[1]));
+ }
}
// Returns the cache for the current canonical path.
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 85f338c..0db6c76 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -19,6 +19,7 @@
{template .Index}
{@param canonicalPath: ?}
{@param staticResourcePath: ?}
+ {@param gerritInitialData: /** {string} map of REST endpoint to response for startup. */ ?}
{@param? assetsPath: ?} /** {string} URL to static assets root, if served from CDN. */
{@param? assetsBundle: ?} /** {string} Assets bundle .html file, served from $assetsPath. */
{@param? faviconPath: ?}
@@ -44,6 +45,17 @@
{if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
{if $assetsPath}window.ASSETS_PATH = '{$assetsPath}';{/if}
{if $polymer2}window.POLYMER2 = true;{/if}
+ {if $gerritInitialData}
+ // INITIAL_DATA is a string that represents a JSON map. It's inlined here so that we can
+ // spare calls to the API when starting up the app.
+ // The map maps from endpoint to returned value. This matches Gerrit's REST API 1:1, so the
+ // values here can be used as a drop-in replacement for calls to the API.
+ //
+ // Example:
+ // '/config/server/version' => '3.0.0-468-g0757b52a7d'
+ // '/accounts/self/detail' => { 'username' : 'gerrit-user' }
+ window.INITIAL_DATA = JSON.parse({$gerritInitialData});
+ {/if}
</script>{\n}
{if $faviconPath}