This is a popular interview question. It is meant to be done within 45 minutes. The task is to implement a thread-safe performant in-memory URL Shortener.
My implementation is based on two maps that store shortened url to original
mapping and original to shortened
mapping. Given the selected alphabet
and keyLength
are large enough, there should be very few collisions. If they still occur, I try to mitigate them by retrying generating a new key 3 times as well as ensuring there is no race condition at the time I add the key (by executing the ConcurrentHashMap.compute()
block which is atomic and blocking).
Questions: I am interested to hear any feedback about the concurrency aspects of the solution as well as the general feedback. Did I overengineer anything? Is it thread safe? Are there possible concurrency issues? Would you expect anybody to write something like this in 45 minutes during an interview (what if there are also tests required)?
public final class UrlShortener {
private final Map<String, String> urlToKey = new ConcurrentHashMap<>();
private final Map<String, String> keyToUrl = new ConcurrentHashMap<>();
private final String alphabet;
private final Random random;
private final int keyLength;
public UrlShortener(String alphabet, Random random, int keyLength) {
if (alphabet == null || alphabet.isEmpty())
throw new IllegalArgumentException("Alphabet must not be empty");
if (random == null) throw new IllegalArgumentException("Random must not be null");
if (keyLength <= 0) throw new IllegalArgumentException("keyLength must be positive");
this.alphabet = alphabet;
this.random = random;
this.keyLength = keyLength;
}
public String addUrl(String url) {
if (url == null) throw new IllegalArgumentException("URL must not be null");
urlToKey.computeIfAbsent(url, (urlToAdd) -> {
var key = newUniqueKey(3);
keyToUrl.compute(key, (keyToAdd, existingValue) -> {
if (existingValue != null) throw new IllegalStateException("Failed to add url");
return url;
});
return key;
});
return urlToKey.get(url);
}
public String getUrl(String key) {
if (key == null) throw new IllegalArgumentException("requested key must not be null");
return keyToUrl.get(key);
}
String generateRandomKey() {
var sb = new StringBuilder();
for (int i = 0; i < keyLength; i++) {
int index = random.nextInt(alphabet.length());
sb.append(alphabet.charAt(index));
}
return sb.toString();
}
String newUniqueKey(int attempts) {
var key = generateRandomKey();
var attemptsMade = 1;
while (keyToUrl.containsKey(key) && attemptsMade < attempts) {
key = generateRandomKey();
attemptsMade++;
}
if (keyToUrl.containsKey(key))
throw new IllegalStateException("Failed to generate a key");
return key;
}
}
In terms of collision probability, if the alphabet is for instance 62 chars (uppercase and lowercase English letters and numbers) and the key length is 10 it would produce a space of more than 8 * 10^17 unique keys. Assuming there are let's say 1 000 000 stored keys, the probability of generating a collision would be less than one per 8 * 10^11 for a single attempt, but then I perform three attempts which reduces the probability significantly.
EDIT: Adding tests as was requested in answers:
class UrlShortenerTest {
UrlShortener urlShortener;
Random random;
@BeforeEach
void before() {
var alphabet = "ab";
var keyLength = 2;
random = mock(Random.class);
urlShortener = new UrlShortener(alphabet, random, keyLength);
}
@Test
void shouldGenerateRandomKey() {
given(random.nextInt(2)).willReturn(1, 0);
var key = urlShortener.generateRandomKey();
assertEquals("ba", key);
}
@Test
void shouldGetNewUniqueKey() {
given(random.nextInt(2)).willReturn(1, 0);
var attempts = 3;
var key = urlShortener.newUniqueKey(attempts);
assertEquals("ba", key);
}
@Test
void shouldFailToGetNewUniqueKeyGivenCollision() {
given(random.nextInt(2)).willReturn(1, 0, 1, 0, 1, 0, 1, 0);
urlShortener.addUrl("https://example.com");
var exception = assertThrows(IllegalStateException.class, () -> urlShortener.newUniqueKey(3));
assertEquals("Failed to generate a key", exception.getMessage());
}
@Test
void shouldGetNewUniqueKeyFromThirdAttempt() {
given(random.nextInt(2)).willReturn(1, 0, 1, 0, 1, 0, 0, 1);
urlShortener.addUrl("https://example.com");
var attempts = 3;
var uniqueKey = urlShortener.newUniqueKey(attempts);
assertEquals("ab", uniqueKey);
}
@Test
void shouldAddUrl() {
given(random.nextInt(2)).willReturn(1, 0);
var key = urlShortener.addUrl("https://example.com");
assertEquals("ba", key);
}
@Test
void shouldGetUrl() {
given(random.nextInt(2)).willReturn(1, 0);
var key = urlShortener.addUrl("https://example.com");
var url = urlShortener.getUrl(key);
assertEquals("https://example.com", url);
}
@Test
void shouldFailToGenerateUniqueKeyWhenKeySpaceExhausted() {
given(random.nextInt(2)).willReturn(0, 0, 1, 1, 0, 1, 1, 0);
urlShortener.addUrl("https://example1.com");
urlShortener.addUrl("https://example2.com");
urlShortener.addUrl("https://example3.com");
urlShortener.addUrl("https://example4.com");
var exception = assertThrows(IllegalStateException.class, () -> urlShortener.addUrl("https://example5.com"));
assertEquals("Failed to generate a key", exception.getMessage());
}
}
synchronized
for modifications and still doesn't appear to be 100% race-free.) That said, while possibly an interesting exercise in itself, this would be a lot more work than just implementing a URL shortener. \$\endgroup\$