source: josm/trunk/src/org/openstreetmap/josm/data/imagery/TMSCachedTileLoaderJob.java@ 19050

Last change on this file since 19050 was 19050, checked in by taylor.smock, 15 months ago

Revert most var changes from r19048, fix most new compile warnings and checkstyle issues

Also, document why various ErrorProne checks were originally disabled and fix
generic SonarLint issues.

  • Property svn:eol-style set to native
File size: 15.4 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.data.imagery;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.io.ByteArrayInputStream;
7import java.io.IOException;
8import java.net.SocketTimeoutException;
9import java.net.URL;
10import java.nio.charset.StandardCharsets;
11import java.util.HashSet;
12import java.util.List;
13import java.util.Locale;
14import java.util.Map;
15import java.util.Map.Entry;
16import java.util.Optional;
17import java.util.Set;
18import java.util.concurrent.ConcurrentHashMap;
19import java.util.concurrent.ConcurrentMap;
20import java.util.concurrent.ThreadPoolExecutor;
21import java.util.concurrent.TimeUnit;
22import java.util.regex.Matcher;
23import java.util.regex.Pattern;
24
25import org.apache.commons.jcs3.access.behavior.ICacheAccess;
26import org.apache.commons.jcs3.engine.behavior.ICache;
27import org.openstreetmap.gui.jmapviewer.Tile;
28import org.openstreetmap.gui.jmapviewer.interfaces.TileJob;
29import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
30import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
31import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
32import org.openstreetmap.josm.data.cache.BufferedImageCacheEntry;
33import org.openstreetmap.josm.data.cache.CacheEntry;
34import org.openstreetmap.josm.data.cache.CacheEntryAttributes;
35import org.openstreetmap.josm.data.cache.ICachedLoaderListener;
36import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob;
37import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
38import org.openstreetmap.josm.data.imagery.vectortile.mapbox.MVTFile;
39import org.openstreetmap.josm.data.preferences.LongProperty;
40import org.openstreetmap.josm.tools.HttpClient;
41import org.openstreetmap.josm.tools.Logging;
42import org.openstreetmap.josm.tools.Utils;
43
44/**
45 * Class bridging TMS requests to JCS cache requests
46 *
47 * @author Wiktor Niesiobędzki
48 * @since 8168
49 */
50public class TMSCachedTileLoaderJob extends JCSCachedTileLoaderJob<String, BufferedImageCacheEntry> implements TileJob, ICachedLoaderListener {
51 /** General maximum expires for tiles. Might be overridden by imagery settings */
52 public static final LongProperty MAXIMUM_EXPIRES = new LongProperty("imagery.generic.maximum_expires", TimeUnit.DAYS.toMillis(30));
53 /** General minimum expires for tiles. Might be overridden by imagery settings */
54 public static final LongProperty MINIMUM_EXPIRES = new LongProperty("imagery.generic.minimum_expires", TimeUnit.HOURS.toMillis(1));
55 static final Pattern SERVICE_EXCEPTION_PATTERN = Pattern.compile("(?s).+<ServiceException[^>]*>(.+)</ServiceException>.+");
56 static final Pattern CDATA_PATTERN = Pattern.compile("(?s)\\s*<!\\[CDATA\\[(.+)\\]\\]>\\s*");
57 static final Pattern JSON_PATTERN = Pattern.compile("\\{\"message\":\"(.+)\"\\}");
58 protected final Tile tile;
59 private volatile URL url;
60 private final TileJobOptions options;
61
62 // we need another deduplication of Tile Loader listeners, as for each submit, new TMSCachedTileLoaderJob was created
63 // that way, we reduce calls to tileLoadingFinished, and general CPU load due to surplus Map repaints
64 private static final ConcurrentMap<String, Set<TileLoaderListener>> inProgress = new ConcurrentHashMap<>();
65
66 /**
67 * Constructor for creating a job, to get a specific tile from cache
68 * @param listener Tile loader listener
69 * @param tile to be fetched from cache
70 * @param cache object
71 * @param options for job (such as http headers, timeouts etc.)
72 * @param downloadExecutor that will be executing the jobs
73 */
74 public TMSCachedTileLoaderJob(TileLoaderListener listener, Tile tile,
75 ICacheAccess<String, BufferedImageCacheEntry> cache,
76 TileJobOptions options,
77 ThreadPoolExecutor downloadExecutor) {
78 super(cache, options, downloadExecutor);
79 this.tile = tile;
80 this.options = options;
81 if (listener != null) {
82 inProgress.computeIfAbsent(getCacheKey(), k -> new HashSet<>()).add(listener);
83 }
84 }
85
86 @Override
87 public String getCacheKey() {
88 if (tile != null) {
89 TileSource tileSource = tile.getTileSource();
90 return Optional.ofNullable(tileSource.getName()).orElse("").replace(ICache.NAME_COMPONENT_DELIMITER, "_")
91 + ICache.NAME_COMPONENT_DELIMITER
92 + tileSource.getTileId(tile.getZoom(), tile.getXtile(), tile.getYtile());
93 }
94 return null;
95 }
96
97 /*
98 * this doesn't needs to be synchronized, as it's not that costly to keep only one execution
99 * in parallel, but URL creation and Tile.getUrl() are costly and are not needed when fetching
100 * data from cache, that's why URL creation is postponed until it's needed
101 *
102 * We need to have static url value for TileLoaderJob, as for some TileSources we might get different
103 * URL's each call we made (servers switching), and URL's are used below as a key for duplicate detection
104 *
105 */
106 @Override
107 public URL getUrl() throws IOException {
108 if (url == null) {
109 synchronized (this) {
110 if (url == null) {
111 String sUrl = tile.getUrl();
112 if (!"".equals(sUrl)) {
113 url = new URL(sUrl);
114 }
115 }
116 }
117 }
118 return url;
119 }
120
121 @Override
122 public boolean isObjectLoadable() {
123 if (cacheData != null) {
124 byte[] content = cacheData.getContent();
125 try {
126 return content.length > 0 || cacheData.getImage() != null || isNoTileAtZoom();
127 } catch (IOException e) {
128 Logging.logWithStackTrace(Logging.LEVEL_WARN, e, "JCS TMS - error loading from cache for tile {0}: {1}",
129 tile.getKey(), e.getMessage());
130 }
131 }
132 return false;
133 }
134
135 @Override
136 protected boolean isResponseLoadable(Map<String, List<String>> headers, int statusCode, byte[] content) {
137 attributes.setMetadata(tile.getTileSource().getMetadata(headers));
138 if (tile.getTileSource().isNoTileAtZoom(headers, statusCode, content)) {
139 attributes.setNoTileAtZoom(true);
140 return false; // do no try to load data from no-tile at zoom, cache empty object instead
141 }
142 if (isNotImage(headers, statusCode)) {
143 String message = detectErrorMessage(new String(content, StandardCharsets.UTF_8));
144 if (!Utils.isEmpty(message)) {
145 tile.setError(message);
146 }
147 return false;
148 }
149 return super.isResponseLoadable(headers, statusCode, content);
150 }
151
152 private boolean isNotImage(Map<String, List<String>> headers, int statusCode) {
153 if (statusCode == 200 && headers.containsKey("Content-Type") && !headers.get("Content-Type").isEmpty()) {
154 String contentType = headers.get("Content-Type").stream().findAny().orElse(null);
155 if (contentType != null && !contentType.startsWith("image") && !MVTFile.MIMETYPE.contains(contentType.toLowerCase(Locale.ROOT))) {
156 Logging.warn("Image not returned for tile: " + url + " content type was: " + contentType);
157 // not an image - do not store response in cache, so next time it will be queried again from the server
158 return true;
159 }
160 }
161 return false;
162 }
163
164 @Override
165 protected boolean cacheAsEmpty(Map<String, List<String>> headerFields, int responseCode) {
166 if (isNotImage(headerFields, responseCode)) {
167 return false;
168 }
169 return isNoTileAtZoom() || super.cacheAsEmpty(headerFields, responseCode);
170 }
171
172 @Override
173 public void submit(boolean force) {
174 tile.initLoading();
175 try {
176 super.submit(this, force);
177 } catch (IOException | IllegalArgumentException e) {
178 // if we fail to submit the job, mark tile as loaded and set error message
179 Logging.log(Logging.LEVEL_WARN, e);
180 tile.finishLoading();
181 tile.setError(e.getMessage());
182 }
183 }
184
185 @Override
186 public void loadingFinished(CacheEntry object, CacheEntryAttributes attributes, LoadResult result) {
187 this.attributes = attributes; // as we might get notification from other object than our selfs, pass attributes along
188 Set<TileLoaderListener> listeners = inProgress.remove(getCacheKey());
189 boolean status = result == LoadResult.SUCCESS;
190
191 try {
192 tile.finishLoading(); // whatever happened set that loading has finished
193 // set tile metadata
194 if (this.attributes != null) {
195 for (Entry<String, String> e: this.attributes.getMetadata().entrySet()) {
196 tile.putValue(e.getKey(), e.getValue());
197 }
198 }
199
200 switch (result) {
201 case SUCCESS:
202 handleNoTileAtZoom();
203 if (attributes != null) {
204 int httpStatusCode = attributes.getResponseCode();
205 if (httpStatusCode >= 400 && !isNoTileAtZoom()) {
206 status = false;
207 handleError(attributes);
208 }
209 }
210 status &= tryLoadTileImage(object); //try to keep returned image as background
211 break;
212 case FAILURE:
213 handleError(attributes);
214 tryLoadTileImage(object);
215 break;
216 case CANCELED:
217 tile.loadingCanceled();
218 // do nothing
219 }
220
221 // always check, if there is some listener interested in fact, that tile has finished loading
222 if (listeners != null) { // listeners might be null, if some other thread notified already about success
223 for (TileLoaderListener l: listeners) {
224 l.tileLoadingFinished(tile, status);
225 }
226 }
227 } catch (IOException e) {
228 Logging.warn("JCS TMS - error loading object for tile {0}: {1}", tile.getKey(), e.getMessage());
229 tile.setError(e);
230 tile.setLoaded(false);
231 if (listeners != null) { // listeners might be null, if some other thread notified already about success
232 for (TileLoaderListener l: listeners) {
233 l.tileLoadingFinished(tile, false);
234 }
235 }
236 }
237 }
238
239 private void handleError(CacheEntryAttributes attributes) {
240 if (tile.hasError() && tile.getErrorMessage() != null) {
241 // tile has already set error message, don't overwrite it
242 return;
243 }
244 if (attributes != null) {
245 int httpStatusCode = attributes.getResponseCode();
246 if (attributes.getErrorMessage() == null) {
247 tile.setError(tr("HTTP error {0} when loading tiles", httpStatusCode));
248 } else {
249 tile.setError(tr("Error downloading tiles: {0}", attributes.getErrorMessage()));
250 }
251 if (httpStatusCode >= 500 && httpStatusCode != 599) {
252 // httpStatusCode = 599 is set by JCSCachedTileLoaderJob on IOException
253 tile.setLoaded(false); // treat 500 errors as temporary and try to load it again
254 }
255 // treat SocketTimeoutException as transient error
256 attributes.getException()
257 .filter(x -> x.isAssignableFrom(SocketTimeoutException.class))
258 .ifPresent(x -> tile.setLoaded(false));
259 } else {
260 tile.setError(tr("Problem loading tile"));
261 // treat unknown errors as permanent and do not try to load tile again
262 }
263 }
264
265 /**
266 * For TMS use BaseURL as settings discovery, so for different paths, we will have different settings (useful for developer servers)
267 *
268 * @return base URL of TMS or server url as defined in super class
269 */
270 @Override
271 protected String getServerKey() {
272 TileSource ts = tile.getSource();
273 if (ts instanceof AbstractTMSTileSource) {
274 return ((AbstractTMSTileSource) ts).getBaseUrl();
275 }
276 return super.getServerKey();
277 }
278
279 @Override
280 protected BufferedImageCacheEntry createCacheEntry(byte[] content) {
281 return new BufferedImageCacheEntry(content);
282 }
283
284 @Override
285 public void submit() {
286 submit(false);
287 }
288
289 @Override
290 protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) {
291 CacheEntryAttributes ret = super.parseHeaders(urlConn);
292 // keep the expiration time between MINIMUM_EXPIRES and MAXIMUM_EXPIRES, so we will cache the tiles
293 // at least for some short period of time, but not too long
294 long minimumExpiryTime = TimeUnit.SECONDS.toMillis(options.getMinimumExpiryTime());
295 long nowPlusMin = now + Math.max(MINIMUM_EXPIRES.get(), minimumExpiryTime);
296 if (ret.getExpirationTime() < nowPlusMin) {
297 ret.setExpirationTime(nowPlusMin);
298 }
299 long nowPlusMax = now + Math.max(MAXIMUM_EXPIRES.get(), minimumExpiryTime);
300 if (ret.getExpirationTime() > nowPlusMax) {
301 ret.setExpirationTime(nowPlusMax);
302 }
303 return ret;
304 }
305
306 private boolean handleNoTileAtZoom() {
307 if (isNoTileAtZoom()) {
308 Logging.debug("JCS TMS - Tile valid, but no file, as no tiles at this level {0}", tile);
309 tile.setError(tr("No tiles at this zoom level"));
310 tile.putValue("tile-info", "no-tile");
311 return true;
312 }
313 return false;
314 }
315
316 private boolean isNoTileAtZoom() {
317 if (attributes == null) {
318 Logging.warn("Cache attributes are null");
319 }
320 return attributes != null && attributes.isNoTileAtZoom();
321 }
322
323 private boolean tryLoadTileImage(CacheEntry object) throws IOException {
324 if (object != null) {
325 byte[] content = object.getContent();
326 if (content.length > 0 || tile instanceof VectorTile) {
327 try (ByteArrayInputStream in = new ByteArrayInputStream(content)) {
328 tile.loadImage(in);
329 if ((!(tile instanceof VectorTile) && tile.getImage() == null)
330 || ((tile instanceof VectorTile) && !tile.isLoaded())) {
331 String s = new String(content, StandardCharsets.UTF_8);
332 Matcher m = SERVICE_EXCEPTION_PATTERN.matcher(s);
333 if (m.matches()) {
334 String message = Utils.strip(m.group(1));
335 tile.setError(message);
336 Logging.error(message);
337 Logging.debug(s);
338 } else {
339 tile.setError(tr("Could not load image from tile server"));
340 }
341 return false;
342 }
343 } catch (UnsatisfiedLinkError | SecurityException e) {
344 throw new IOException(e);
345 }
346 }
347 }
348 return true;
349 }
350
351 @Override
352 public String detectErrorMessage(String data) {
353 Matcher xml = SERVICE_EXCEPTION_PATTERN.matcher(data);
354 Matcher json = JSON_PATTERN.matcher(data);
355 return xml.matches() ? removeCdata(Utils.strip(xml.group(1)))
356 : json.matches() ? Utils.strip(json.group(1))
357 : super.detectErrorMessage(data);
358 }
359
360 private static String removeCdata(String msg) {
361 Matcher m = CDATA_PATTERN.matcher(msg);
362 return m.matches() ? Utils.strip(m.group(1)) : msg;
363 }
364}
Note: See TracBrowser for help on using the repository browser.