7
\$\begingroup\$

After reading Designing a coffee machine, I decided to implement the same problem as an exercise to get to know Guava and Lombok.

I used the problem statement from the given question:

Design a coffee machine which makes different beverages based on set ingredients. The initialization of the recipes for each drink should be hard-coded, although it should be relatively easy to add new drinks. The machine should display the ingredient stock (+cost) and menu upon startup, and after every piece of valid user input. Drink cost is determined by the combination of ingredients. For example, Coffee is 3 units of coffee (75 cents per), 1 unit of sugar (25 cents per), 1 unit of cream (25 cents per). Ingredients and Menu items should be printed in alphabetical order. If the drink is out of stock, it should print accordingly. If the drink is in stock, it should print "Dispensing: ". To select a drink, the user should input a relevant number. If they submit "r" or "R" the ingredients should restock, and "q" or "Q" should quit. Blank lines should be ignored, and invalid input should print an invalid input message.

They supplied the default ingredients (&stock @10) and drinks/recipes.

In my version, I attempted to do the following:

  • Make everything immutable that is logical to be immutable
  • Separate the user-io from the data structures as much as possible, so that designing a different user interface becomes easy.
  • Make object creation as painless as possible through use of the builder pattern

I'm not sure if the builder pattern was the right way to go, however.

Main.java

package coffee;

import com.google.common.collect.Range;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.SortedMap;

public class Main {
    public static void main(String[] args) {
        DrinkMachine drinkMachine = DrinkMachine.builder()
                .drink(Recipe.builder().name("Coffee")
                        .ingredient(new IngredientListing("Coffee", Money.valueOf(0.75), 3))
                        .ingredient(new IngredientListing("Sugar", Money.valueOf(0.25), 1))
                        .ingredient(new IngredientListing("Cream", Money.valueOf(0.25), 1))
                        .build())
                .drink(Recipe.builder().name("Decaf Coffee")
                        .ingredient(new IngredientListing("Decaf Coffee", Money.valueOf(0.75), 3))
                        .ingredient(new IngredientListing("Sugar", Money.valueOf(0.25), 1))
                        .ingredient(new IngredientListing("Cream", Money.valueOf(0.25), 1))
                        .build())
                .drink(Recipe.builder().name("Caffe Latte")
                        .ingredient(new IngredientListing("Espresso", Money.valueOf(1.10), 2))
                        .ingredient(new IngredientListing("Steamed Milk", Money.valueOf(0.35), 1))
                        .build())
                .drink(Recipe.builder().name("Caffe Americano")
                        .ingredient(new IngredientListing("Espresso", Money.valueOf(1.10), 3))
                        .build())
                .drink(Recipe.builder().name("Caffe Mocha")
                        .ingredient(new IngredientListing("Espresso", Money.valueOf(1.10), 1))
                        .ingredient(new IngredientListing("Steamed Milk", Money.valueOf(0.35), 1))
                        .ingredient(new IngredientListing("Cocoa", Money.valueOf(0.90), 1))
                        .ingredient(new IngredientListing("Whipped Cream", Money.valueOf(1), 1))
                        .build())
                .drink(Recipe.builder().name("Cappuccino")
                        .ingredient(new IngredientListing("Espresso", Money.valueOf(1.10), 2))
                        .ingredient(new IngredientListing("Steamed Milk", Money.valueOf(0.35), 1))
                        .ingredient(new IngredientListing("Foamed Milk", Money.valueOf(0.35), 1))
                        .build())
                .build();

        boolean running = true;
        BufferedReader inputReader = new BufferedReader(new InputStreamReader(System.in));
        do {
            printStock(drinkMachine.getStock());
            printMenu(drinkMachine.getDrinks());
            System.out.println("Which drink would you like to choose (q for quit)?");
            String input;
            try {
                do {
                    input = inputReader.readLine();
                } while (input.trim().equals(""));
            } catch (IOException e) {
                throw new IllegalStateException("Unexpected IO error; couldn't read user input");
            }
            if (input.equalsIgnoreCase("q")) {
                running = false;
                System.out.println("Goodbye!");
            } else if (input.equalsIgnoreCase("r")) {
                System.out.println("Restocking ingredients.");
                drinkMachine.restock();
            } else if (input.matches("\\d+")) {
                int chosenIndex = Integer.parseInt(input) - 1;
                if (Range.closedOpen(0, drinkMachine.getDrinks().size()).contains(chosenIndex)) {
                    Recipe chosenDrink = drinkMachine.getDrinks().get(chosenIndex);
                    if (drinkMachine.drinkInStock(chosenDrink)) {
                        drinkMachine.makeDrink(chosenDrink);
                        System.out.printf("Dispensing: %s%n", chosenDrink.getName());
                    } else {
                        System.out.println("Drink out of stock. Consider restocking (r)");
                    }
                } else {
                    System.out.println("Sorry, that wasn't a valid option.");
                }
            } else {
                System.out.println("Sorry, that wasn't a valid option.");
            }
        } while (running);
    }

    private static void printStock(SortedMap<Ingredient, Integer> stock) {
        stock.forEach((ingredient, stockCount) -> {
            System.out.printf("%s: %d%n", ingredient.getName(), stockCount);
        });
        System.out.println();
    }

    private static void printMenu(List<Recipe> drinks) {
        System.out.println("Menu:");
        for (int i = 0; i < drinks.size(); i++) {
            System.out.printf("%d: %s %s%n", i + 1, drinks.get(i).getPrice(), drinks.get(i).getName());
        }
        System.out.println("r: Restock");
        System.out.println("q: Quit");
        System.out.println();
    }
}

DrinkMachine.java

package coffee;

import com.google.common.collect.ImmutableList;
import lombok.*;

import java.util.*;

import static com.google.common.base.Preconditions.checkArgument;

@Data
public class DrinkMachine {
    private static final int DEFAULT_STOCK_VALUE = 10;
    private final ImmutableList<Recipe> drinks;
    private final SortedMap<Ingredient, Integer> stock;
    private Money internalCash;

    @Builder
    public DrinkMachine(@Singular("drink") @NonNull List<Recipe> drinks) {
        drinks = new ArrayList<>(drinks);
        drinks.sort(Recipe.NAME_COMPARATOR);
        this.drinks = ImmutableList.copyOf(drinks);
        this.stock = new TreeMap<>(Ingredient.NAME_COMPARATOR);
        restock();
        this.internalCash = Money.ZERO;
    }

    public void restock() {
        restock(DEFAULT_STOCK_VALUE);
    }

    public void restock(int toValue) {
        for (Recipe recipe : drinks) {
            recipe.getIngredientList().stream().map(IngredientListing::getIngredient).forEach((ingredient -> {
                stock(ingredient, DEFAULT_STOCK_VALUE);
            }));
        }
    }

    public void stock(Ingredient ingredient, int stockCount) {
        stock.put(ingredient, stockCount);
    }

    public SortedMap<Ingredient, Integer> getStock() {
        return Collections.unmodifiableSortedMap(stock);
    }

    public boolean drinkInStock(Recipe recipe) {
        return recipe.getIngredientList().stream()
                .allMatch(listing -> stock.getOrDefault(listing.getIngredient(), 0) >= listing.getNumber());
    }

    public void makeDrink(Recipe recipe) {
        reduceStockByRecipe(recipe);
        this.internalCash = this.internalCash.add(recipe.getPrice());
    }

    private void reduceStockByRecipe(Recipe recipe) {
        checkArgument(drinkInStock(recipe), "Not enough ingredients to make drink %s", recipe);
        for (IngredientListing listing : recipe.getIngredientList()) {
            stock.compute(listing.getIngredient(), (key, value) -> value - listing.getNumber());
        }
    }
}

Recipe.java

package coffee;

import com.google.common.collect.ImmutableList;
import lombok.Builder;
import lombok.NonNull;
import lombok.Singular;
import lombok.Value;

import java.util.*;

import static com.google.common.base.Preconditions.checkArgument;

@Value
@Builder
public class Recipe {
    public static final Comparator<Recipe> NAME_COMPARATOR = (a, b) -> a.getName().compareTo(b.getName());
    private final String name;
    @Singular("ingredient")
    private final ImmutableList<IngredientListing> ingredientList;

    @java.beans.ConstructorProperties({ "name", "ingredientList" })
    public Recipe(@NonNull String name, @NonNull List<IngredientListing> ingredientList) {
        this.name = name;
        validateIngredientList(ingredientList);
        ingredientList = new ArrayList<>(ingredientList);
        ingredientList.sort(IngredientListing.NAME_COMPARATOR);
        this.ingredientList = ImmutableList.copyOf(ingredientList);
    }

    public Money getPrice() {
        return ingredientList.stream()
                .map(IngredientListing::getPrice)
                .reduce(Money::add).get();
    }

    private void validateIngredientList(List<IngredientListing> ingredientList) {
        Set<Ingredient> knownIngredients = new HashSet<>();
        for (IngredientListing listing : ingredientList) {
            checkArgument(!knownIngredients.contains(listing.getIngredient()),
                    "Ingredient %s was declared multiple times in the recipe", listing.getIngredient().getName());
            knownIngredients.add(listing.getIngredient());
        }
    }
}

Ingredient.java

package coffee;

import lombok.EqualsAndHashCode;
import lombok.Value;

import java.util.Comparator;

@Value
@EqualsAndHashCode(exclude = { "price" }) // Consider differently priced versions of the same ingredient to be the same
public class Ingredient {
    public static final Comparator<Ingredient> NAME_COMPARATOR = (a, b) -> a.getName().compareTo(b.getName());
    public static final Comparator<Ingredient> PRICE_COMPARATOR = (a, b) -> a.getPrice().compareTo(b.getPrice());
    private String name;
    private Money price;
}

IngredientListing.java

package coffee;

import lombok.AllArgsConstructor;
import lombok.NonNull;
import lombok.Value;

import java.util.Comparator;

@Value
@AllArgsConstructor
public class IngredientListing {
    public static final Comparator<IngredientListing> NAME_COMPARATOR = (a, b) -> Ingredient.NAME_COMPARATOR.compare(a.ingredient, b.ingredient);
    public static final Comparator<IngredientListing> PRICE_COMPARATOR = (a, b) -> a.getPrice().compareTo(b.getPrice());
    @NonNull
    private Ingredient ingredient;
    private int number;

    public IngredientListing(@NonNull String name, @NonNull Money price, int number) {
        ingredient = new Ingredient(name, price);
        this.number = number;
    }

    public Money getPrice() {
        return ingredient.getPrice().multiply(number);
    }
}

Money.java

package coffee;

import lombok.NonNull;
import lombok.Value;
import lombok.val;

import java.math.BigInteger;

import static com.google.common.base.Preconditions.checkArgument;

@Value
public class Money implements Comparable<Money> {
    public static final Money ZERO = Money.valueOf(0);
    private static final String MONEY_REGEX = "\\$?\\d+(\\.\\d\\d)?" +
            "|\\$?(\\.\\d\\d)?";
    @NonNull
    private BigInteger value;

    public Money multiply(int num) {
        return new Money(value.multiply(BigInteger.valueOf(num)));
    }

    public Money add(@NonNull Money m) {
        return new Money(value.add(m.getValue()));
    }

    public Money subtract(@NonNull Money m) {
        return new Money(value.subtract(m.getValue()));
    }

    public String toString() {
        val x = value.divideAndRemainder(BigInteger.valueOf(100));
        return "$" + x[0] + "." + String.format("%02d", x[1].intValue());
    }

    public static Money parseMoney(@NonNull String representation) {
        checkArgument(representation.matches(MONEY_REGEX), "Invalid money specifier for \"%s\"", representation);
        return new Money(new BigInteger(representation.replaceAll("\\D", "")));
    }

    public static Money valueOf(@NonNull String representation) {
        return parseMoney(representation);
    }

    public static Money valueOf(double value) {
        return parseMoney(String.format("%.2f", value));
    }

    @Override
    public int compareTo(Money money) {
        return getValue().compareTo(money.getValue());
    }
}
\$\endgroup\$
4
\$\begingroup\$

Money

I'm not exactly sure why you need a String-based valueOf() method here, when you are only using the other one that takes double values. Wouldn't it be simpler to just have your Money class accept cents as the base unit, and then set BigInteger value with BigInteger.valueOf()?

Receipe and the builder pattern

Lombok's builder pattern removes boilerplate code at the (slight) expense of flexibility in expressiveness. What I mean by that is that while it's nice for it to readily provide the name() and ingredient() methods, sometimes you may encounter other patterns that:

  • Use an external -Builder class, i.e. ReceipeBuilder in your case,
  • Use verbs to describe the action, i.e. addIngredient() instead of ingredient() (I understand this is just a simple change on your part),
  • Specify the name at the final build 'step', e.g. ReceipeBuilder.addIngredient(...).create("name").

My take on this is that there's really no issues with using Lombok's builder pattern, but at the same time don't be too limited by what you can do through a framework. :)

I think Receipe.validateIngredientList() can be made redundant if you were to work with an Set/ImmutableSet directly, instead of a List. Even if you will like to stick to this current approach, this method can be rewritten as such to leverage on the return value from Set.add, which is true only if the addition modifies the Set:

private void validateIngredientList(List<IngredientListing> ingredientList) {
    Set<Ingredient> knownIngredients = new HashSet<>();
    for (IngredientListing listing : ingredientList) {
        checkArgument(knownIngredients.add(listing.getIngredient()),
                "Ingredient %s was declared multiple times in the recipe", 
                listing.getIngredient().getName());
    }
}

IngredientListing

You can consider using enum types for your various ingredients, if you are fine with defining the 'universe' of ingredients first.

Comparator

Since you are already using a healthy dose of Java 8 features, there's the newer Comparator.comparing() method that (arguably) makes these declarations more readable, e.g.

@Value
@EqualsAndHashCode(exclude = { "price" })
public class Ingredient {
    public static final Comparator<Ingredient> NAME_COMPARATOR = 
                            Comparator.comparing(Ingredient::getName);
    public static final Comparator<Ingredient> PRICE_COMPARATOR = 
                            Comparator.comparing(Ingredient::getPrice);
    private String name;
    private Money price;
}

Now, I must say this is totally untested as I don't have Lombok... maybe the method reference way of declaration might not work.

Main

  • Recommended to put the declaration of your drinks into its own method, e.g. private static DrinkMachine createDrinks().
  • Reading from the console can be done and validated in its own method, e.g. private static String getInput(Scanner).
  • Short of turning your collection of drinks into an enum type so that you can reference their ordinal() methods, here is another alternative implementation of displaying it that you may want to consider:

    private static void printMenu(List<Recipe> drinks) {
        System.out.println("Menu:");
        int[] counter = new int[1];
        drinks.forEach(drink -> System.out.printf("%d: %s %s%n", ++counter[0],
                        drink.getPrice(), drink.getName());
        System.out.println("r: Restock");
        System.out.println("q: Quit");
        System.out.println();
    }
    

    The trick here is to rely on a single-element int[] array (effectively making a stream-friendly counting Object) as an index for the drink to be displayed.

\$\endgroup\$
  • 1
    \$\begingroup\$ Your uncertainty of whether or not it works in Lombok is well-founded, but worry not. Lombok actually generates the methods and they are full-fledged as if they were literally coded, so it works perfectly. Using an array is such a brilliant idea for stream-friendly objects; I've been struggling with the effectively-final restriction up 'til now. I definitely learned a lot from your answer. Thank you. \$\endgroup\$ – Justin Aug 18 '15 at 6:10
  • \$\begingroup\$ @Justin glad to help! I learned about the array way almost a year ago from my other question: codereview.stackexchange.com/questions/63766/… \$\endgroup\$ – h.j.k. Aug 18 '15 at 6:29
  • \$\begingroup\$ Is it better to use a Scanner or a BufferedReader when reading from stdin? The way I understand it, Scanners are more powerful, but BufferedReaders are faster. \$\endgroup\$ – Justin Aug 20 '15 at 7:11
  • \$\begingroup\$ @Justin they have different purposes. BufferedReader is to make the reading from a Reader source more efficient, but Scanner gives you the ability to parse the inputs. I suppose the parsing feature makes it 'more powerful'. BufferedReader's Javadoc only mentions wrapping it "around any Reader whose read() operations may be costly, such as FileReaders and InputStreamReaders.". \$\endgroup\$ – h.j.k. Aug 20 '15 at 7:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.