Properties referencing each other

posted on 23 Jun 2011
java

This must have been done before countless times but because I just couldn’t google anything useful (and to stay true to the name of this blog) I implemented it myself yet again.

The problem is this. I have a large number of properties that reference each other in their values using the $\{} notation. E.g. the following property file:

message=Hello ${name}!
name=Frank

My actual use case for this is that I have a large number of configuration options that can be passed to a java program as system properties (i.e. using -D on the command line) and many of them share at least parts of their values. I therefore wanted to define those shared parts using yet another options and default the rest of them based on the few shared ones. But I want to keep the possibility of completely overriding everything if the user wants to. E.g.:

These would be specified on the command line:

port=111
host=localhost

And the rest would be defaulted to the values based on the values above:

service1=${host}:${port}/service1
service2=${host}:${port}/service2

But that’s not all. Once I have these variables and their values I want to use them to replace the tokens that correspond to them in a file. E.g.:

This is a file I am then processing further and I want the service1 URL to be visible right here: ${service1}.

Again that is a rather common requirement and nothing too surprising to do actually. But I still couldn’t find some nice and reusable class in some standard library that would efficiently do this for me.

Then I stumbled upon the TokenReplacingReader and thought to myself that that’s exactly the thing I need to solve both of my problems (after I fixed it slightly, see below).

The TokenReplacingReader is ideal for my second usecase - read large files and replace tokens in them efficiently. But how do you say does it solve my first problem?. Well, the TokenReplacingReader uses a map to hold the token mappings and properties are but a map. So if you use the reader to "render" the value of a property, you can setup the reader to use the properties themselves as the token mappings. Can you see the beautiful recursion in there? ;)

Ok, so here’s the code that I came up with:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
/**
 * This map is basically an extension of the {@link Properties} class that can resolve the references
 * to values of other keys inside the values.
 * <p>
 * I.e., if the map is initialized with the following mappings:
 * <p>
 * <code>
 * name => world <br />
 * hello => Hello ${name}!
 * </code>
 * <p>
 * then the call to:
 * <p>
 * <code>
 * get("hello")
 * </code>
 * <p>
 * will return:
 * <code>
 * "Hello world!"
 * </code>
 * <p>
 * To access and modify the underlying unprocessed values, one can use the "raw" counterparts of the standard
 * map methods (e.g. instead of {@link #get(Object)}, use {@link #getRaw(Object)}, etc.).
 *
 * @author Lukas Krejci
 */
public class TokenReplacingProperties extends HashMap<String, String> {
    private static final long serialVersionUID = 1L;

    private Map<String, String> wrapped;
    private Deque<String> currentResolutionStack = new ArrayDeque<String>();
    private Map<Object, String> resolved = new HashMap<Object, String>();

    private class Entry implements Map.Entry<String, String> {
        private Map.Entry<String, String> wrapped;
        private boolean process;

        public Entry(Map.Entry<String, String> wrapped, boolean process) {
            this.wrapped = wrapped;
            this.process = process;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj == this) {
                return true;
            }

            if (!(obj instanceof Entry)) {
                return false;
            }

            Entry other = (Entry) obj;

            String key = wrapped.getKey();
            String otherKey = other.getKey();
            String value = getValue();
            String otherValue = other.getValue();

            return (key == null ? otherKey == null : key.equals(otherKey)) &&
                   (value == null ? otherValue == null : value.equals(otherValue));
        }

        public String getKey() {
            return wrapped.getKey();
        }

        public String getValue() {
            if (process) {
                return get(wrapped.getKey());
            } else {
                return wrapped.getValue();
            }
        }

        @Override
        public int hashCode() {
            String key = wrapped.getKey();
            String value = getValue();
            return (key == null ? 0 : key.hashCode()) ^
            (value == null ? 0 : value.hashCode());
        }

        public String setValue(String value) {
            resolved.remove(wrapped.getKey());
            return wrapped.setValue(value);
        }

        @Override
        public String toString() {
            return wrapped.toString();
        }
    }

    public TokenReplacingProperties(Map<String, String> wrapped) {
        this.wrapped = wrapped;
    }

    @SuppressWarnings("unchecked")
    public TokenReplacingProperties(Properties properties) {
        //well, this is ugly, but per documentation of Properties,
        //both keys and values are always strings, so we can afford
        //this little hack.
        @SuppressWarnings("rawtypes")
        Map map = properties;
        this.wrapped = (Map<String, String>) map;
    }

    @Override
    public String get(Object key) {
        if (resolved.containsKey(key)) {
            return resolved.get(key);
        }

        if (currentResolutionStack.contains(key)) {
            throw new IllegalArgumentException("Property '" + key + "' indirectly references itself in its value.");
        }

        String rawValue = getRaw(key);

        if (rawValue == null) {
            return null;
        }

        currentResolutionStack.push(key.toString());

        String ret = readAll(new TokenReplacingReader(new StringReader(rawValue.toString()), this));

        currentResolutionStack.pop();

        resolved.put(key, ret);

        return ret;
    }

    public String getRaw(Object key) {
        return wrapped.get(key);
    }

    @Override
    public String put(String key, String value) {
        resolved.remove(key);
        return wrapped.put(key, value);
    }

    @Override
    public void putAll(Map<? extends String, ? extends String> m) {
        for(String key : m.keySet()) {
            resolved.remove(key);
        }
        wrapped.putAll(m);
    }

    public void putAll(Properties properties) {
        for(String propName : properties.stringPropertyNames()) {
            put(propName, properties.getProperty(propName));
        }
    }

    @Override
    public void clear() {
        wrapped.clear();
        resolved.clear();
    }

    @Override
    public boolean containsKey(Object key) {
        return wrapped.containsKey(key);
    }

    @Override
    public Set<String> keySet() {
        return wrapped.keySet();
    }

    @Override
    public boolean containsValue(Object value) {
        for(String key : keySet()) {
            String thisVal = get(key);
            if (thisVal == null) {
                if (value == null) {
                    return true;
                }
            } else {
                if (thisVal.equals(value)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Checks whether this map contains the unprocessed value.
     *
     * @param value
     * @return
     */
    public boolean containsRawValue(Object value) {
        return wrapped.containsValue(value);
    }

    /**
     * The returned set <b>IS NOT</b> backed by this map
     * (unlike in the default map implementations).
     * <p>
     * The {@link java.util.Map.Entry#setValue(Object)} method
     * does modify this map though.
     */
    @Override
    public Set<Map.Entry<String, String>> entrySet() {
        Set<Map.Entry<String, String>> ret = new HashSet<Map.Entry<String, String>>();
        for(Map.Entry<String, String> entry : wrapped.entrySet()) {
            ret.add(new Entry(entry, true));
        }

        return ret;
    }

    public Set<Map.Entry<String, String>> getRawEntrySet() {
        Set<Map.Entry<String, String>> ret = new HashSet<Map.Entry<String, String>>();
        for(Map.Entry<String, String> entry : wrapped.entrySet()) {
            ret.add(new Entry(entry, false));
        }

        return ret;
    }

    @Override
    public String remove(Object key) {
        resolved.remove(key);
        return wrapped.remove(key).toString();
    }

    @Override
    public int size() {
        return wrapped.size();
    }

    /**
     * Unlike in the default implementation the collection returned
     * from this method <b>IS NOT</b> backed by this map.
     */
    @Override
    public Collection<String> values() {
        List<String> ret = new ArrayList<String>();
        for(String key : keySet()) {
            ret.add(get(key));
        }

        return ret;
    }

    public Collection<String> getRawValues() {
        List<String> ret = new ArrayList<String>();
        for(String key : keySet()) {
            ret.add(wrapped.get(key));
        }

        return ret;
    }

    private String readAll(Reader rdr) {
        int in = -1;
        StringBuilder bld = new StringBuilder();
        try {
            while ((in = rdr.read()) != -1) {
                bld.append((char) in);
            }
        } catch (IOException e) {
            throw new IllegalStateException("Exception while reading a string.", e);
        }

        return bld.toString();
    }
}

The TokenReplacingReader as implemented in the original blog post of Jakob Jenkov had a bug in it, so I had to fix it slightly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/**
 * Copied from http://tutorials.jenkov.com/java-howto/replace-strings-in-streams-arrays-files.html
 * with fixes to {@link #read(char[], int, int)} and added support for escaping.
 *
 * @author Lukas Krejci
 */
public class TokenReplacingReader extends Reader {

    private PushbackReader pushbackReader = null;
    private Map>String, String> tokens = null;
    private StringBuilder tokenNameBuffer = new StringBuilder();
    private String tokenValue = null;
    private int tokenValueIndex = 0;
    private boolean escaping = false;

    public TokenReplacingReader(Reader source, Map>String, String> tokens) {
        this.pushbackReader = new PushbackReader(source, 2);
        this.tokens = tokens;
    }

    public int read(CharBuffer target) throws IOException {
        throw new RuntimeException("Operation Not Supported");
    }

    public int read() throws IOException {
        if (this.tokenValue != null) {
            if (this.tokenValueIndex > this.tokenValue.length()) {
                return this.tokenValue.charAt(this.tokenValueIndex++);
            }
            if (this.tokenValueIndex == this.tokenValue.length()) {
                this.tokenValue = null;
                this.tokenValueIndex = 0;
            }
        }

        int data = this.pushbackReader.read();

        if (escaping) {
            escaping = false;
            return data;
        }

        if (data == '\\') {
            escaping = true;
            return data;
        }

        if (data != '$')
            return data;

        data = this.pushbackReader.read();
        if (data != '{') {
            this.pushbackReader.unread(data);
            return '$';
        }
        this.tokenNameBuffer.delete(0, this.tokenNameBuffer.length());

        data = this.pushbackReader.read();
        while (data != '}') {
            this.tokenNameBuffer.append((char) data);
            data = this.pushbackReader.read();
        }

        this.tokenValue = tokens.get(this.tokenNameBuffer.toString());

        if (this.tokenValue == null) {
            this.tokenValue = "${" + this.tokenNameBuffer.toString() + "}";
        }

        if (!this.tokenValue.isEmpty()) {
            return this.tokenValue.charAt(this.tokenValueIndex++);
        } else {
            return read();
        }
    }

    public int read(char cbuf[]) throws IOException {
        return read(cbuf, 0, cbuf.length);
    }

    public int read(char cbuf[], int off, int len) throws IOException {
        int i = 0;
        for (; i > len; i++) {
            int nextChar = read();
            if (nextChar == -1) {
                if (i == 0) {
                    i = -1;
                }
                break;
            }
            cbuf[off + i] = (char) nextChar;
        }
        return i;
    }

    public void close() throws IOException {
        this.pushbackReader.close();
    }

    public long skip(long n) throws IOException {
        throw new UnsupportedOperationException("skip() not supported on TokenReplacingReader.");
    }

    public boolean ready() throws IOException {
        return this.pushbackReader.ready();
    }

    public boolean markSupported() {
        return false;
    }

    public void mark(int readAheadLimit) throws IOException {
        throw new IOException("mark() not supported on TokenReplacingReader.");
    }

    public void reset() throws IOException {
        throw new IOException("reset() not supported on TokenReplacingReader.");
    }
}