Getting started with Java
— java — 21 min read
Installing JDK
Use Amazon Correto which is a multiplatform, production-ready distribution of the Open Java Development Kit (OpenJDK)
Packages in use
javax.ws.rs
A Java API for RESTful web services. It provides a set of classes and interfaces that can be used to create and consume RESTful web services and was introduced in Java EE 6
@Path("api/v1/chat")public interface ChatResource { @POST @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @Path("prompt") UUID createPrompt(@Valid @NotNull CreateChatRequest createChatRequest);}
javax.validation.constraints
Provides a set of annotations that can be used to validate the values of fields and properties in Java objects.
import javax.validation.constraints.NotEmpty;import javax.validation.constraints.NotNull;
public class CreateChatRequest { @NotNull @NotEmpty private final String prompt;}
Arrays
Arrays have a fixed size once they're created like other programming languages.
Java doesn't have a spread operator like JavaScript. However, you can achieve this operation using the System.arraycopy
method or using libraries like Apache Commons Lang and its ArrayUtils
class.
import org.apache.commons.lang3.ArrayUtils;
YourClass[] originalArray = ...;YourClass newElement = ...;
YourClass[] newArray = ArrayUtils.add(originalArray, newElement);
Filtering array
- Create a
Stream
and filter with the predicate lambda function - Convert to typed array, use
toArray(T::new)
var messages = Array.stream(messages) .filter(x => x.getAuthor().equals(bot)) .toArray(Message[]::new);
Add an item to an array
ArrayUtils.add(messages, new Message(BOT, content));
Constants
Constant is a value that cannot be changed once assigned. Constants are not supported directly in Java. Instead, there is an alternative way to define a constant in Java by using the static
and final
keywords.
static final String location = "us-central1";static final String publisher = "google";
Constants class
I like chucking in all constants in one place
public final class Constants { static final String user = "user"; static final String publisher = "publisher"; static final String content = "content";}
Generic Value converter
To make your method generic, you can introduce a type parameter, say <T>
, and then use this type parameter in the method signature. Here's your updated method using generics:
private static <T> Value getValue(T parameters) throws InvalidProtocolBufferException { Value.Builder parameterValueBuilder = Value.newBuilder(); JsonFormat .parser() .merge(new Gson().toJson(parameters), parameterValueBuilder);
return parameterValueBuilder.build();}
Now the getValue
method can accept any type of object as its parameters
argument, and it will convert it to a Value
using the provided logic. This approach makes the method more flexible and reusable.
UUID
Creating a random UUID
DefaultUUIDGenerator.randomUUID()
DateTime
Use InstanceSource
Instant instance = instanceSource.instant();
POJO (Plain Old Java Object)
Request
public class SendChatRequest { @NotNull @NotEmpty private final String prompt;
@NotNull @NotEmpty private final String model;
public SendChatRequest( @JsonProperty("model") String model, @JsonProperty("prompt") String prompt ) { this.model = model; this.prompt = prompt; }
@JsonProperty("prompt") publicl String getPrompt() { return prompt; }
@JsonProperty("model") publicl String getModel() { return model; }}
ApiResponse
It's pretty cool to have a structured response, no matter if it's a success or not. This way, the client using the endpoint doesn't need to deal with the response in a special way based on the API response's status.
# 200 OK{ "data": { "promptResponse": "This is an awesome place to live" }}
# 400 Bad Request{ "errors": [ "The model name is not provided" ]}
public class ApiResponse<T> { private final T data; private final String[] errors;
public ApiResponse( @JsonProperty("data") T data ) { this.data = data; this.errors = new String[]{}; }
public ApiDataResponse( @JsonProperty("errors") String[] errors ) { this.data = null; this.errors = errors; }
public T getData() { return data; } public String[] getErrors() { return errors; }}
Response
public class SendChatResponse { private final String chatResponse;
@JsonCreator public SendChatResponse( @JsonProperty("chatResponse") String chatResponse ) { this.chattResponse = chatResponse; }
public String getChatResponse() { return chatResponse; }}
final
means an immutable field.- The
@NotNull
annotation suggests that this field must not benull
@JsonCreator
: This annotation indicates that the annotated constructor or factory method should be used to create instances of the containing class during deserialization by libraries like Jackson (a popular Java library for processing JSON).@JsonProperty("chatResponse")
: This annotation is used in conjunction with Jackson to specify that thechatResponse
parameter in the constructor should be populated with the value associated with thechatResponse
key in the incoming JSON object.
DTO for JSON
public class Instance { private final String prompt;
public Instance(String prompt) { this.prompt = prompt; } public String getPrompt() { return prompt; } @Override public String toString() { return new Gson().toJson(this); }}
Configuration interface and class
@ConfigurationType@ImplementedBy(ChatServiceConfigurationImpl.class)public interface ChatServiceConfiguration { @ConfigurationProperty( key = "gcp.chat.service.account.json.credentials", description = "JSON project credentials" ) String getGoogleCredentialsConfig();
default GoogleCredentials getGoogleCredentials() { var credentials = getGoogleCredentialsConfig() .getBytes(StandardCharsets.UTF_8); return ExternalAccountCredentials .fromStream(new ByteArrayInputStream(credentials)); }}
@Singletonpublic class ChatServiceConfigurationImpl implements ChatServiceConfiguration { private final Supplier<String> getProjectName; private final Supplier<String> getGoogleCredentialsConfig;
@Override public String getProjectName() { return getProjectName.get(); } @Override public String getGoogleCredentialsConfig() { return getGoogleCredentialsConfig.get() }}
@ImplementedBy(ChatServiceConfigurationImpl.class)
: a dependency injection framework, Google Guice. The annotation indicates that by default,ChatServiceConfigurationImpl.class
provides the implementation of theChatServiceConfiguration
interface. In other words, if someone asks the DI framework for an instance ofChatServiceConfiguration
, and no binding has been explicitly configured, it would useChatServiceConfigurationImpl.class
.- Default Method:
- This is a default method provided in the interface, which means implementing classes do not need to provide their own implementation unless they want to override this behavior.
- The method is designed to:
- Fetch the Google credentials as a string using
getGoogleCredentialsConfig()
. - Convert that string to bytes using UTF-8 encoding.
- Create a
ByteArrayInputStream
with those bytes. - Use the
fromStream
method ofExternalAccountCredentials
(presumably a part of the Google Cloud SDK) to convert that byte stream into aGoogleCredentials
object.
- Fetch the Google credentials as a string using
- This method abstracts away the process of converting the JSON credentials string into an actual
GoogleCredentials
object that can be used to interact with Google Cloud services.
Supplier
In Java, the Supplier<T>
is a functional interface introduced in Java 8, found in the java.util.function
package. A functional interface is an interface that contains just one abstract method, and thus can represent lambda expressions targeting it.
The primary purpose of Supplier<T>
is to represent a function that takes no arguments and returns a result of type T
. In simpler terms, it supplies a value of type T
.
Here's the basic structure of the Supplier<T>
interface:
@FunctionalInterfacepublic interface Supplier<T> { T get();}
Enum
An enumeration (enum) is a special data type that enables for a variable to be a set of predefined constants. The variable must be equal to one of the values that have been predefined for it. Enumerations are used when you have values that you know aren't going to change, like month days, days, colors, deck of cards, etc.
Here is a simple example of an enumeration in Java:
public enum Day { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY}
Features of Enum:
Compatible with String: This is the best feature of Java enum, in my opinion. In C#, enum is from int
and gets converted to number by default. It can cause an error if you change the order of enum values as SUNDAY
was 0 until yesterday but 1 from today if you change the order. Java enum converts to String
and you don’t have the same issue. 31/10/2023
Strongly Typed: Enumerations are strongly typed, meaning that an enum of one type cannot be assigned to an enum of another type even if their underlying values are the same.
Namespace: Enums are implicitly static final, meaning they have a fixed set of constants. The constants are always in uppercase letters.
Ability to Use Enum in Switch Statements: Enumerations can be used in switch statements.
Values() Method: You can iterate over the values of an enum class with the values()
method.
ValueOf() Method: You can use the valueOf()
method to get the enum constant of the specified string value, if it exists.
Constructors, Fields, and Methods: Enumerations can have constructors, fields, and methods.
Example with Constructors, Fields, and Methods:
public enum Planet { MERCURY (3.303e+23, 2.4397e6), VENUS (4.869e+24, 6.0518e6), EARTH (5.976e+24, 6.37814e6), // ... other planets ...
private final double mass; // in kilograms private final double radius; // in meters
Planet(double mass, double radius) { this.mass = mass; this.radius = radius; }
private double mass() { return mass; } private double radius() { return radius; }
// universal gravitational constant (m3 kg-1 s-2) public static final double G = 6.67300E-11;
double surfaceGravity() { return G * mass / (radius * radius); } double surfaceWeight(double otherMass) { return otherMass * surfaceGravity(); }}
In this example, each enum constant is declared with values for mass and radius. These values are passed to the constructor when the constant is created. Java requires that the constants be defined first, prior to any fields or methods. Also, when there are fields and methods, the list of enum constants must end with a semicolon.
Note that the constructor for an enum type must be package-private or private access. It automatically creates the constants that are defined at the beginning of the enum body. You cannot invoke an enum constructor yourself.
Record
Java records were introduced as a feature in Java 14 as a preview feature and were stabilized in Java 16. They provide a quick and compact way to model immutable data in Java. A record class is a special kind of class in Java that is designed to model immutable data in applications.
Characteristics of Records:
Immutable: Once a record is created, its state cannot change. All fields in a record are final.
Conciseness: You do not need to write boilerplate code such as getters, setters, equals()
, hashCode()
, and toString()
methods. The Java compiler automatically generates these for you.
Public Fields: All fields in a record are public and final.
Canonical Constructor: A record comes with a canonical constructor, which is a constructor with parameters for all the fields in the record.
Compact Syntax: You can define a record with a very compact syntax compared to regular classes.
- Don’t forget overriding
toString()
Syntax:
public record RecordName(Type field1, Type field2, ...) { // Additional methods and annotations can go here @Override public String toString() { return "RecordName{" + "field1=" + field1 + "}"; }}
Example:
public record Person(String name, int age) { }
In this example, Person
is a record with two fields: name
and age
. You do not need to manually create a constructor, getters, or equals()
, hashCode()
, and toString()
methods. The Java compiler generates these for you.
Using Records:
public class Main { public static void main(String[] args) { Person person = new Person("John", 25); System.out.println(person.name()); // Prints: John System.out.println(person.age()); // Prints: 25 System.out.println(person); // Prints: Person[name=John, age=25] }}
When to Use Records:
- Use records when you want to model immutable data.
- They are great for data transfer objects (DTOs), value objects, and messages.
Limitations of Records:
- Records cannot extend any other class and cannot be extended. They implicitly extend
java.lang.Record
. - They cannot declare instance fields other than the private final fields which correspond to components of the state description. Any other fields must be static.
- They are implicitly final, so you cannot create a subclass of a record.
Records provide a clean and concise way to model immutable data in Java, reducing boilerplate code and improving readability.
Override toString()
By default, it’ll return the content of the fields. If the record
contains any sensitive information, override the toString()
so that it doesn’t get logged in any logging.
public record Message(Author author, String content) { @Override public String toString() { return "Message{" + "author='" + author + "\'" + ", content='...'" + "}"; }}
Stream
Stream
is an interface that represents a sequence of elements supporting sequential and parallel aggregate operations. Introduced in Java 8, Stream
API provides a modern and functional approach to processing collections of objects. The Stream API is in the java.util.stream
package.
Key Characteristics of Java Streams:
No Storage: Streams don't store elements. They carry values from a source (like a collection or an array) through a pipeline of computational steps.
Functional in Nature: Streams facilitate functional-style operations on elements, such as map-reduce transformations.
Lazy Execution: Stream operations are lazily executed. This means computation on the source data is only performed when necessary for the terminal operation.
Possibly Unbounded: While collections have a finite size, streams need not. They can represent fixed-size collections, infinite streams, or compute elements on-demand.
Consumable: Streams are designed to be consumed only once. After a terminal operation is performed, the stream cannot be reused.
Core Components of Stream API:
- Stream Sources: Collections, arrays, or I/O channels can serve as sources for streams.
- Intermediate Operations: These operations transform a stream into another stream, such as
filter
,map
,limit
,sorted
, etc. They are lazy, meaning they're not executed until a terminal operation is invoked. - Terminal Operations: These operations produce a result or a side-effect, such as
forEach
,reduce
,collect
,findFirst
, etc. Once a terminal operation is performed, the stream is consumed and cannot be used further.
Basic Example of a Stream:
import java.util.Arrays;import java.util.List;import java.util.stream.Collectors;
public class StreamExample { public static void main(String[] args) { List<String> myList = Arrays.asList("a1", "a2", "b1", "c2", "c1");
List<String> filtered = myList.stream() // Create a stream .filter(s -> s.startsWith("c")) // Intermediate operation .map(String::toUpperCase) // Intermediate operation .sorted() // Intermediate operation .collect(Collectors.toList()); // Terminal operation
filtered.forEach(System.out::println); // Outputs: C1, C2 }}
Parallel Streams:
Java streams can be processed in parallel to leverage multicore architectures. Parallel streams divide the provided task into many and run them in parallel, which can lead to significant performance improvements. However, parallel processing can be more complex and may not always lead to better performance, especially for small datasets or operations that are not CPU-intensive.
Optional
java.util.Optional<T>
is a container object which may or may not contain a non-null value. It was introduced in Java 8 as a means to provide a clear and explicit way to convey the idea of optionality, thereby avoiding null
checks and NullPointerException
s.
Here are some key points about Optional
:
Avoiding Null: Before Optional
, a method could return null
to indicate that no value was present, but that could easily lead to NullPointerException
s if the caller didn't diligently check for null
. With Optional
, the intent is clear—there might not be a value.
API Usage: Methods that might not be able to return a result can return an instance of Optional<T>
instead of potentially returning null
. The caller of the method must then explicitly deal with the possibility that there might not be a value present.
Common Methods:
Optional.empty()
: Returns an emptyOptional
instance.Optional.of(T value)
: Returns anOptional
with the specified present non-null value.Optional.ofNullable(T value)
: Returns anOptional
describing the specified value, or an empty if the value isnull
.Optional.isPresent()
: Returnstrue
if there is a value present, otherwisefalse
.Optional.get()
: If a value is present, this method returns the value, otherwise throwsNoSuchElementException
.Optional.orElse(T other)
: Returns the value if present, otherwise returnsother
.Optional.orElseGet(Supplier<? extends T> other)
: Returns the value if present, otherwise invokesother
and returns the result of that invocation.Optional.orElseThrow(Supplier<? extends X> exceptionSupplier)
: Returns the contained value if present, otherwise throws an exception to be created by the provided supplier.Optional.ifPresent(Consumer<? super T> consumer)
: If a value is present, invoke the specified consumer with the value, otherwise do nothing.
- Chaining Methods:
Optional
supports fluent API style operations that includemap
,flatMap
,filter
, etc., which can be chained together to perform complex conditional operations.
Here's a simple example of using Optional
:
public class OptionalExample { public static Optional<String> findString(List<String> list, String str) { for (String s : list) { if (s.equals(str)) { return Optional.of(s); } } return Optional.empty(); }
public static void main(String[] args) { List<String> list = Arrays.asList("a", "b", "c"); Optional<String> result = findString(list, "b");
result.ifPresent(System.out::println); // Prints "b" System.out.println(result.orElse("not found")); // Prints "b"
Optional<String> notFound = findString(list, "d"); System.out.println(notFound.orElse("not found")); // Prints "not found" }}
Google Protocol Buffers
Protocol Buffers (often abbreviated as protobuf) is a method developed by Google to serialize structured data, similar to XML or JSON. It is both simpler and more efficient than both XML and JSON. Google's Protocol Buffers are defined in .proto
files, which is a kind of schema for the serialized data.
The Value
message is part of the protobuf's "well-known type" for working with a dynamic or loosely structured data model, similar to how you might use JSON in other contexts. Specifically, Value
is a part of the Struct
type which allows for flexible, map-like data structures.
Here's a breakdown of the Value
message:
- null_value: A special value that represents an empty value.
- number_value: Represents a double value.
- string_value: Represents a string value.
- bool_value: Represents a boolean value.
- struct_value: Represents a structured value using the
Struct
message type. - list_value: Represents a list of values using the
ListValue
message type.
The Value
message allows for flexibility since any Value
can contain any kind of data - be it a simple number, a string, a more complex structure, or even a list of other values.
An example of its utility is when using Google Cloud services, such as the Datastore, which can store a variety of data types. The Value
type can encapsulate all these varieties.
Holding JSON in Value
private static Value getParametersValue(Parameters parameters) { var = Map.of( "temperature", Value .newBuilder() .setNumberValue(parameters.getTemperature()) .build(), "maxOutputTokens", Value .newBuilder() .setNumberValue(parameters.getMaxOutputTokens()) .build(), );
return Value .newBuilder() .setStructValue(Struct.newBuilder().putAllFields(valueMap)) .build();}
Array vs. Lists
Both Array
and List
are fundamental concepts in Java, but they have distinct characteristics and use cases. Let's delve into each:
Array:
- Definition: An array in Java is a low-level data structure that holds a fixed number of values of a single type.
- Size: Once you declare the size of an array, it's fixed. You cannot change it without creating a new array.
- Types: Java supports both primitive and reference type arrays.
- Primitive type arrays:
int[]
,char[]
,float[]
, etc. - Reference type arrays:
String[]
,Object[]
,CustomClass[]
, etc.
- Primitive type arrays:
- Memory: Arrays are stored in contiguous memory locations.
- Performance: Access to an array element by its index is very fast, O(1). However, operations like inserting and removing an element in the middle require manual shifting of elements and are O(n).
- Usage: Arrays are beneficial when the data size is known in advance, and changes to the data size are infrequent.
List:
- Definition:
List
is a part of the Java Collections Framework and implements theCollection
interface. It is a higher-level, dynamic data structure compared to arrays. TheList
interface has various implementations such asArrayList
,LinkedList
, etc. - Size: Lists are dynamic. You can add or remove elements, and the list will resize dynamically.
- Types: Lists only support reference types. Even when you have a list of primitives (like
int
), Java uses their wrapper classes (likeInteger
). - Memory: Depending on the implementation, the memory structure may differ:
ArrayList
internally uses an array to store its elements. When it reaches its capacity, a new, larger array is created, and the old elements are transferred.LinkedList
uses a doubly-linked list data structure, where each element (node) contains a value and references to the next and previous nodes.
- Performance:
ArrayList
offers constant-time performance for indexed access and iteration, but O(n) for insertions and deletions in the middle.LinkedList
provides O(1) for insertions and deletions (if the node is known), but O(n) for indexed access.
- Usage: Lists, especially
ArrayList
, are more common in standard applications due to their dynamic nature and the vast set of built-in methods provided by the Java Collections Framework.
Google Guice
Google Guice (pronounced “juice”) is a lightweight dependency injection framework for Java 5 and above, brought to you by Google. Dependency injection is a design pattern that allows for more modular and testable code by removing hard-coded dependencies between classes, making it easier to swap out components for testing or maintenance.
Core Concepts:
Injection: This is the process by which the dependencies of a class are ‘injected’ or provided to the class by an external entity, instead of the class creating them internally.
Binder: Guice uses a binding API to configure the injector, which is responsible for injecting dependencies. This is usually done in a Module.
Module: A module is where you define your bindings, which tell Guice how to map your injections. This is where you can configure which implementation of an interface to use, or what constant values to inject.
Injector: The injector is what creates objects and provides dependencies. You ask the injector to provide an instance of a particular class, and it takes care of creating that object and any dependencies that it has.
Provider: A provider is a factory for creating instances. Guice will use a provider when you need to provide a custom way of creating an instance of a type.
Scope: Guice allows you to control the lifecycle of your objects via scopes. The most common scopes are Singleton (one instance per Injector) and Prototype (a new instance every time).
Example:
Here is a simple example of how to use Guice:
public interface MessageService { String getMessage();}
public class EmailService implements MessageService { public String getMessage() { return "Sent via Email"; }}
public class MessageModule extends AbstractModule { @Override protected void configure() { bind(MessageService.class).to(EmailService.class); }}
public class Application { private final MessageService service;
@Inject public Application(MessageService service) { this.service = service; }
public void sendMessage() { System.out.println(service.getMessage()); }
public static void main(String[] args) { Injector injector = Guice.createInjector(new MessageModule()); Application app = injector.getInstance(Application.class); app.sendMessage(); }}
In this example, we have a MessageService
interface with an implementation EmailService
. The MessageModule
class is our Guice module where we define our bindings. The Application
class has a dependency on MessageService
, which is injected through its constructor.
When we run the application, Guice takes care of creating the Application
object, figuring out that it needs a MessageService
, creating an EmailService
to satisfy this dependency, and then injecting it.
This results in a flexible and decoupled design, where the Application
class doesn’t need to know about how to create a MessageService
, and it's easy to replace EmailService
with a different implementation of MessageService
if needed.
Provides
You can provide a concrete class by using @Provides
. Used in Provider pattern injection
public class ModelModule extends AbstractModule { @Provides public ServiceClient serviceClient(ServiceConfiguration config) throws IOException { var endpoint = String.format("%s-...", config.getLocation()); var credentials = config.getCredentials(); var credentialsProvider = FixedCredentialsProvider.create(credentials); var setting = ServiceSettings .newBuilder() .setCredentialsProvider(credentialsProvider) .setEndpoint(endpoint) .build();
return ServiceClient.create(settings); }}
public class ModelService { private final Provider<ServiceClient> serviceClientProvider; public ModelService( Provider<ServiceClient> serviceClientProvider ) { this.serviceClientProvider = serviceClientProvider; }
try (var serviceClient = serviceClientProvider.get()) { ... }}
Testing
class ChatServiceTest { @Inject private ChatService underTest; private final ChatServiceConfiguraton chatServiceConfiguraton = mock(ChatServiceConfiguraton.class);
@BeforeEach void setUp() { Guice.createInjector( binder -> { binder.bind(ChatServiceConfiguraton.class) .toInstance(chatServiceConfiguraton); } ).injectMember(this)
underTest = new ChatService(chatServiceConfiguraton); }
@Test void sendPrompt() { var response = underTest .sendPrompt(new SendPromptRequest("Explain what Mockto is")); assertThat(response.getPromptResponse()).isNotEmpty(); }}
Mocking with Mockito
Mockito is a mocking framework that allows you to create and configure mock objects. Using Mockito, you can mock interfaces, generate stubs, and verify interactions between objects in your tests. It's a favorite tool in the Java world for unit testing because it enables you to write clean tests with a clear API.Let's discuss the code snippet you provided with Mockito in mind:
When you use mocks in tests, you typically follow these steps:
- Mock Creation: You create a mock object for the dependency.
- Stubbing: You provide a "fake" behavior or return value for some methods of the mock object.
- Running Code: You run your code under test.
- Verification: You verify if certain methods on the mock object were called.
class ChatServiceTest { @Inject private ChatService underTest; private final PredictionService predictionService = mock(PredictionService.class); private final String promptResponse = "The bank is awesome"; private final String model = "text-bison@001"; private final String prompt = "Explain the bank";
@BeforeEach void setUp() throws IOException { Guice.createInjector( binder -> { binder.bind(PredictionService.class) .toInstance(predictionService); } ).injectMember(this)
when(predictionService.predict(model, prompt)).thenReturn(promptResponse); underTest = new ChatService(chatServiceConfiguraton); }
@Test void sendPrompt() { var response = underTest .sendPrompt(new SendPromptRequest(model, prompt)); assertThat(response.getPromptResponse()).isEqualTo(promptResponse); }}
Assert exception
@Testvoid sendPrompt_returns_error_message() throws IOException { String exceptionMessage = "Value conversion failed"; when(languageModelResource.predict( model, temperature, tokenLimit, prompt )).thenThrow(new RuntimeException(exceptionMessage));
var exception = assertThrows(RuntimeException.class, () -> { underTest.sendPrompt(promptRequest); });
assertThat(exception.getMessage()) .isEqualTo("java.lang.RuntimeException: " + exceptionMessage);}
Match any parameter
To make a Mockito mock return the same value regardless of the parameters it receives, you can use the any()
matcher for the arguments.
import static org.mockito.ArgumentMatchers.any;
when(resource.predict( eq(model), any(), any())).thenReturn(messages);
Here, eq(model)
ensures that the method predict
is called with the exact model
you specified, while any()
matches any value for the subsequent parameters. This way, no matter what parameters
and message
values the predict
method is called with, it will always return the messages
.