How to access data dynamically in Java without losing type safety
An interesting question in the context of information systems is:
To what extent Data-Oriented programming is applicable in a statically-typed language like Java?
The first two principles of Data-Oriented programming (DOP) seem to be in the spirit of the newest additions to Java (e.g data records in Java 14):
- Principle #1: Code is separated from data
- Principle #2: Data is immutable
However, when it comes to Principle #3, it causes discomfort to many Java developers:
By flexible data access, we mean that it should be possible inside our programs to access dynamically a data field, given its name.
There are two ways to provide dynamic data access in Java:
- Represent data with classes (or records in Java 14) and use reflection
- Represent data with string maps
The purpose of this article is to illustrate various ways to access data dynamically in Java, both with classes and maps. Towards the end of the article, we suggest how to keep a bit of type safety even when data access is dynamic.
Data in JSON
Let’s take as an example data from a library catalog with a single book.
Here is an example of a catalog data in JSON:
"items": "books", "booksByIsbn": "978-1779501127": "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] > >, "authorsById": "alan-moore": "name": "Alan Moore", "bookIds": ["978-1779501127"] >, "dave-gibbons": "name": "Dave Gibbons", "bookIds": ["978-1779501127"] > > >
Some pieces of data in our catalog are homogeneous maps of unknown size (e.g. the book index, the author index)
Other pieces of data are heterogeneous maps of fixed size (e.g. a book, a author).
Homogeneous maps of unknown size are usually represented by hash maps, while heterogeneous maps of fixed sized are usually represented with classes.
The example that we are going to use again and again throughout the article, is accessing the title of watchmen inside the catalog and convert it to upper case.
Representing data with records
Java 14 introduced the concept of a data record that provides a first-class means for modelling data-only aggregates.
Here is how our data model would look like with records:
public record AuthorData (String name, ListString> bookIds) <> public record BookData (String title, String isbn, Integer publicationYear, ListString> authorIds) <> public record CatalogData (String items, MapString, BookData> booksByIsbn, MapString, AuthorData> authorByIds) <>
Records are instantiated like classes:
var watchmen = new BookData("Watchmen", "978-1779501127", 1987, List.of("alan-moore", "dave-gibbons")); var alanM = new AuthorData("Alan Moore", List.of("978-1779501127")); var daveG = new AuthorData("Dave Gibbons", List.of("978-1779501127")); var booksByIsbn = Map.of("978-1779501127", watchmen); var authorsById = Map.of("alan-moore", alanM, "dave-gibbons", daveG); var catalog = new CatalogData("books", booksByIsbn, authorsById);
Conceptually, the title of Watchmen, like any other piece of information has an information path:
["booksByIsbn", "978-1779501127", "title"]
However, when we navigate the information path we encounter both records and hash maps:
- The natural way to access data in a record is via the dot notation
- The natural way to access data in a hash map is via the get() method
Here is how we access the title of watchmen and convert it to upper case.
catalog.booksByIsbn().get("978-1779501127") .title().toUpperCase(); // "WATCHMEN"
This lack of uniformity between data access in a record and in a map is not only annoying from a theoretic perspective. It also has practical drawbacks. For instance, we cannot store the information path in a variable or in a function argument. In fact, we don’t have a dynamic access to information.
Accessing data in a record via reflection
We can overcome the drawbacks exposed in the previous section and provide a dynamic access to information in a record or in a class, via reflection.
class DynamicAccess static Object get(Object o, String k) throws IllegalAccessException, NoSuchFieldException return (o.getClass().getDeclaredField(k).get(o)); > >
And now, we are able to access data in a record via a string that holds the name of a field. For instance:
DynamicAccess.get(watchmen, "title") // "Watchmen"
We can easily modify DynamicAccess.get() so that it works both with records and maps:
class DynamicAccess static Object get(Object o, String k) throws IllegalAccessException, NoSuchFieldException if(o instanceof Map) return ((Map)o).get(k); > return (o.getClass().getDeclaredField(k).get(o)); > >
And now, we can write a getIn() method that receives an object and an information path:
class DynamicAccess static Object get(Object o, String k) throws IllegalAccessException, NoSuchFieldException if(o instanceof Map) return ((Map)o).get(k); > return (o.getClass().getDeclaredField(k).get(o)); > static Object getIn(Object o, ListString> path) throws IllegalAccessException, NoSuchFieldException Object v = o; for (String k : path) v = get(v, k); > return v; > >
Here is how we access the title of watchmen in the catalog, via its information path:
var informationPath = List.of("booksByIsbn", "978-1779501127", "title"); DynamicAccess.getIn(catalog, informationPath); // "watchmen"
The problem that remains to be solved is the type of the value that we retrieve via DynamicAccess.get() or DynamicAccess.getIn() .
The most cumbersome way is to cast explicitly:
var informationPath = List.of("booksByIsbn", "978-1779501127", "title"); ((String)DynamicAccess.getIn(catalog, informationPath)) .toUpperCase(); // "WATCHMEN"
Another option is to add two specific methods to DynamicAccess that return a string:
class DynamicAccess static String getAsString(Object o, String k) throws IllegalAccessException, NoSuchFieldException return (String)get(o, k); > static String getInAsString(Object o, ListString> path) throws IllegalAccessException, NoSuchFieldException return (String)getIn(o, path); > >
It makes data access a bit less verbose:
var informationPath = List.of("booksByIsbn", "978-1779501127", "title"); DynamicAccess.getInAsString(catalog, informationPath) .toUpperCase(); // "WATCHMEN"
Representing data with hash maps
Another approach to providing a dynamic data access is to represent every piece of data with hash maps. The benefits of this approach is that we don’t need to use reflection. The drawback is that all our maps are Map and it means that we have lost type safety.
var watchmen = Map.of("title", "Watchmen", "isbn", "978-1779501127", "publicationYear", 1987, "authorIds", List.of("alan-moore", "dave-gibbons")); var alanM = Map.of("name", "Alan Moore", "bookIds", List.of("978-1779501127")); var daveG = Map.of("name", "Dave Gibbons", "bookIds", List.of("978-1779501127")); var booksByIsbn = Map.of("978-1779501127", watchmen); var authorsById = Map.of("alan-moore", alanM, "dave-gibbons", daveG); var catalog = Map.of("items", "book", "booksByIsbn", booksByIsbn, "authorsById", authorsById);
Like before, we are free to access any piece of information via its information path:
var informationPath = List.of("booksByIsbn", "978-1779501127", "title"); DynamicAccess.getInAsString(catalog, informationPath) .toUpperCase(); // "WATCHMEN"
Typed getters
We could move one step further and try to make it easier to specify the type of a value associated with a key, by making field names first-class citizens in our program.
Let’s start with a non-nested key in a map or a record.
We create a generic Getter class:
class Getter T> private String key; public T> Getter (String k) this.key = k; > public T get (Object o) throws IllegalAccessException, NoSuchFieldException return (T)(DynamicAccess.get(o, key)); > >
We can create a typed getter that contains both:
For instance, here is how we create a typed getter for the title of a book:
GetterString> TITLE = new Getter("title");
And here is how we use the typed getter to access the field value:
TITLE.get(watchmen); // "watchmen"
The getter is typed, therefore we can access the value as a string without any casting:
TITLE.get(watchmen).toUpperCase(); // "WATCHMEN"
We can extend the typed getter approach to nested keys:
class GetterIn T> private ListString> path; public T> GetterIn (ListString> path) this.path = path; > T getIn (Object o) throws IllegalAccessException, NoSuchFieldException return (T)(DynamicAccess.getIn(o, path)); > >
And here is how we access a piece of information via its information path:
var informationPath = List.of("booksByIsbn", "978-1779501127", "title"); GetterInString> NESTED_TITLE = new GetterIn(informationPath); NESTED_TITLE.getIn(library).toUpperCase(); // "WATCHMEN"
Conclusion
Providing a dynamic data access in a statically-typed language like Java is challenging. When data is represented with classes or records, we need to use reflection and when data is represented with string maps, we loose the information about types.
Maybe an approach like the typed getters, presented at the end of the article, could open the door to the Java community for a dynamic data access that doesn’t compromise type safety.
Subscribe to Yehonathan Sharvit newsletter
Get the latest and greatest from Yehonathan Sharvit delivered straight to your inbox every week.
Data Access Object (DAO). Уровень класса
При проектировании информационной системы выявляются некоторые слои, которые отвечают за взаимодействие различных модулей системы. Соединение с базой данных является одной из важнейшей составляющей приложения. Всегда выделяется часть кода, модуль, отвечающающий за передачу запросов в БД и обработку полученных от неё ответов. В общем случае, определение Data Access Object описывает его как прослойку между БД и системой. DAO абстрагирует сущности системы и делает их отображение на БД, определяет общие методы использования соединения, его получение, закрытие и (или) возвращение в Connection Pool.
Вершиной иерархии DAO является абстрактный класс или интерфейс с описанием общих методов, которые будут использоваться при взаимодействии с базой данных. Как правило, это методы поиска, удаление по ключу, обновление и т.д.
public abstract class AbstractController < public abstract ListgetAll(); public abstract E getEntityById(K id); public abstract E update(E entity); public abstract boolean delete(K id); public abstract boolean create(E entity); >
Набор методов не является завершённым, он зависит от конкретной системы. Фиктивный тип K является ключом сущности, редкая таблица, описывающая сущность, не имеет первичного ключа. Так же, в данном классе будет логичным разместить метод закрытие экземпляра PrepareStatement.
public void closePrepareStatement(PreparedStatement ps) < if (ps != null) < try < ps.close(); >catch (SQLException e) < e.printStackTrace(); >> >
Уровень класса
Реализация DAO на уровне класса подразумевает использование одного единственного коннекта для вызова более чем одного метода унаследованного DAO класса. В этом случае, в вершине иерархии DAO AbstractController, в качестве поля объявляется connection. Абстрактный класс будет выглядеть следующим образом.
public abstract class AbstractController < private Connection connection; private ConnectionPool connectionPool; public AbstractController() < connectionPool = ConnectionPool.getConnectionPool(); connection = connectionPool.getConnection(); >public abstract List getAll(); public abstract E update(E entity); public abstract E getEntityById(K id); public abstract boolean delete(K id); public abstract boolean create(E entity); // Возвращения экземпляра Connection в пул соединений public void returnConnectionInPool() < connectionPool.returnConnection(connection); >// Получение экземпляра PrepareStatement public PreparedStatement getPrepareStatement(String sql) < PreparedStatement ps = null; try < ps = connection.prepareStatement(sql); >catch (SQLException e) < e.printStackTrace(); >return ps; > // Закрытие PrepareStatement public void closePrepareStatement(PreparedStatement ps) < if (ps != null) < try < ps.close(); >catch (SQLException e) < e.printStackTrace(); >> > >
Стоит отметить, что в данном примере мы получаем экземпляр Connection из пула соединений, что соответственно стоит реализовать или воспользоваться уже готовыми решениями. Создаём методы по получению getPrepareStatement(String sql) и его закрытию closePrepareStatement(PreparedStatement ps) . Реализация конкретного DAO класса, при такой логике, никогда не должна закрывать в своих методах соединение с базой данных. Соединение закрывается в той части бизнес-логики, от куда был вызван метод. Пример конкретного DAO класса будет выглядеть следующим образом.
public class UserController extends AbstractController < public static final String SELECT_ALL_USERS = "SELECT * FROM SHEMA.USER"; @Override public ListgetAll() < Listlst = new LinkedList<>(); PreparedStatement ps = getPrepareStatement(SELECT_ALL_PLANET); try < ResultSet rs = ps.executeQuery(); while (rs.next()) < User user = new User(); planet.setId(rs.getInt(1)); planet.setName(rs.getString(2)); lst.add(user); >> catch (SQLException e) < e.printStackTrace(); >finally < closePrepareStatement(ps); >return lst; > @Override public Planet getEntityById(Integer id) < return null; >@Override public boolean delete(Integer id) < return false; >@Override public boolean create(Planet entity) < return false; >>
public class User implements Serializable < private int id; private String name; public int getId() < return id; >public void setId(int id) < this.id = id; >public String getName() < return name; >public void setName(String name) < this.name = name; >@Override public String toString() < return "User'; > >
Экземпляр Connection доступен методу getPrepareStatement(String sql), который в свою очередь доступен любому методу конкретного DAO класса. Стоит помнить, что следует закрывать экземпляр PrepareStatement сразу после его отработки в блоках finally, а возвращать соединение в пул returnConnectionInPool() в части логики системы, где был вызван метод.