I have this blog lurking around in the web. It's just static HTML, but I was interested to keep track of all the views on that blog. Finally, I end up with the following web application:
(The Java backend resides entirely here.)
com.github.coderodde.weblog.viewcounter.CountViewServlet.java
package com.github.coderodde.weblog.viewcounter;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotAddViewException;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotCreateMainTableException;
import static com.github.coderodde.weblog.viewcounter.Utils.objs;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotGetMostRecenetViewTimeException;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotGetNumberOfViewsException;
import com.google.gson.Gson;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.ZonedDateTime;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* This servlet is responsible for storing the IP-address and the timestamp of
* a view in at <a href="http://coderodde.github.io/weblog/">coderodde's weblog</a>.
*
* @author Rodion "rodde" Efremov
* @version 1.6 (Aug 21, 2021)
* @since 1.6 (Aug 21, 2021)
*/
@WebServlet(name="CountViewServlet", urlPatterns={"/countView"})
public final class CountViewServlet extends HttpServlet {
private static final Gson GSON = new Gson();
private static final Logger LOGGER =
Logger.getLogger(CountViewServlet.class.getName());
@Override
protected void doPost(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse)
throws ServletException, IOException {
// Allow the weblog page to get the response from this servlet:
httpServletResponse.setHeader("Access-Control-Allow-Origin",
"coderodde.github.io");
DataAccessObject dataAccessObject = DataAccessObject.getInstance();
JSONResponseObject jsonResponseObject = new JSONResponseObject();
jsonResponseObject.succeeded = false;
try {
dataAccessObject.createTablesIfNeeded();
ZonedDateTime mostRecentViewTime =
dataAccessObject.getMostRecentViewTime();
if (mostRecentViewTime != null) {
jsonResponseObject.mostRecentViewTime =
mostRecentViewTime.toString();
}
dataAccessObject.addView(httpServletRequest);
jsonResponseObject.numberOfViews = dataAccessObject.getViewCount();
// Mark as successful:
jsonResponseObject.succeeded = true;
} catch (CannotCreateMainTableException ex) {
LOGGER.log(
Level.SEVERE,
"Could not create the main table: {0}, caused by: {1}",
objs(ex.getCause().getMessage(),
ex.getCause().getCause()));
} catch (CannotAddViewException ex) {
LOGGER.log(
Level.SEVERE,
"Could not add a view: {0}, caused by: {1}",
objs(ex.getCause().getMessage(),
ex.getCause().getCause()));
} catch (CannotGetMostRecenetViewTimeException ex) {
LOGGER.log(
Level.SEVERE,
"Could not get the most recent view time: {0}, " +
"caused by: {1}",
objs(ex.getCause().getMessage(),
ex.getCause().getCause()));
} catch (CannotGetNumberOfViewsException ex) {
LOGGER.log(
Level.SEVERE,
"Could not get the number of views: {0}, caused by: {1}",
objs(ex.getCause().getMessage(),
ex.getCause().getCause()));
}
try (PrintWriter printWriter = httpServletResponse.getWriter()) {
printWriter.print(GSON.toJson(jsonResponseObject));
}
}
}
com.github.coderodde.weblog.viewcounter.DataAccessObject.java
package com.github.coderodde.weblog.viewcounter;
import com.github.coderodde.weblog.viewcounter.sql.SQLStatements;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotAddViewException;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotCreateMainTableException;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotGetMostRecenetViewTimeException;
import com.github.coderodde.weblog.viewcounter.exceptions.CannotGetNumberOfViewsException;
import static com.github.coderodde.weblog.viewcounter.Utils.objs;
import java.net.URI;
import java.net.URISyntaxException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.servlet.http.HttpServletRequest;
import org.apache.tomcat.jdbc.pool.DataSource;
/**
* This class implements the data access object for the view counter.
*
* @author Rodion "rodde" Efremov
* @version 1.6 (Aug 22, 2021)
*/
public final class DataAccessObject {
private static final Logger LOGGER =
Logger.getLogger(DataAccessObject.class.getName());
private static final String EUROPE_HELSINKI_ZONE_ID = "Europe/Helsinki";
private static final ZoneId ZONE_ID = ZoneId.of(EUROPE_HELSINKI_ZONE_ID);
private static final String DB_URL_ENVIRONMENT_VARIABLE_NAME =
"CLEARDB_DATABASE_URL";
private static final DataSource dataSource = new DataSource();
private static final DataAccessObject INSTANCE = new DataAccessObject();
public static DataAccessObject getInstance() {
return INSTANCE;
}
/**
* Makes sure that the main table is created.
*
* @throws CannotCreateMainTableException if cannot create the table.
*/
public void createTablesIfNeeded() throws CannotCreateMainTableException {
try (Connection connection = getConnection()) {
connection.createStatement()
.executeUpdate(SQLStatements
.ViewTable
.Create
.CREATE_MAIN_TABLE);
} catch (SQLException cause) {
LOGGER.log(
Level.SEVERE,
"The SQL layer failed: {0}, caused by: {1}",
objs(cause.getMessage(), cause.getCause()));
throw new CannotCreateMainTableException(cause);
} catch (URISyntaxException cause) {
LOGGER.log(
Level.SEVERE,
"URI failed: {0}, caused by: {1}",
objs(cause.getMessage(), cause.getCause()));
throw new CannotCreateMainTableException(cause);
}
}
/**
* Adds a new view data to the database.
*
* @param httpServletRequest the request object.
* @throws com.github.coderodde.weblog.viewcounter.exceptions.CannotAddViewException
* if adding a view data fails.
*/
public void addView(HttpServletRequest httpServletRequest)
throws CannotAddViewException {
String host = httpServletRequest.getRemoteHost();
int port = httpServletRequest.getRemotePort();
String remoteAddress = httpServletRequest.getHeader("X-FORWARDED-FOR");
if (remoteAddress == null) {
remoteAddress = httpServletRequest.getRemoteAddr();
}
try (Connection connection = getConnection();
PreparedStatement statement =
connection.prepareStatement(
SQLStatements.ViewTable.Insert.INSERT_VIEW)) {
statement.setString(1, remoteAddress);
statement.setString(2, host);
statement.setInt(3, port);
ZonedDateTime nowZonedDateTime = ZonedDateTime.now(ZONE_ID);
Timestamp nowTimestamp =
Timestamp.from(nowZonedDateTime.toInstant());
statement.setTimestamp(4, nowTimestamp);
statement.executeUpdate();
} catch (Exception cause) {
throw new CannotAddViewException(cause);
}
}
/**
* Returns the total number of views.
* @return the total number of views so far.
* @throws com.github.coderodde.weblog.viewcounter.exceptions.CannotGetNumberOfViewsException
* if cannot get the number of views.
*/
public int getViewCount() throws CannotGetNumberOfViewsException {
try (Connection connection = getConnection();
Statement statement = connection.createStatement()) {
try (ResultSet resultSet =
statement.executeQuery(
SQLStatements
.ViewTable
.Select
.GET_NUMBER_OF_VIEWS)) {
if (!resultSet.next()) {
throw new IllegalStateException(
"Could not read the number of views.");
}
return resultSet.getInt(1);
}
} catch (Exception ex) {
throw new CannotGetNumberOfViewsException(ex);
}
}
/**
* Returns the most recent view time stamp.
* @return the most recent view time.
* @throws CannotGetMostRecenetViewTimeException if reading the most recent
* view time fails.
*/
public ZonedDateTime getMostRecentViewTime()
throws CannotGetMostRecenetViewTimeException {
try (Connection connection = getConnection();
Statement statement = connection.createStatement()) {
try (ResultSet resultSet =
statement.executeQuery(
SQLStatements
.ViewTable
.Select
.GET_MOST_RECENT_VIEW_TIME)) {
if (!resultSet.next()) {
return null;
}
Timestamp mostRecentViewTimestamp = resultSet.getTimestamp(1);
if (mostRecentViewTimestamp == null) {
return null;
}
ZonedDateTime mostRecentViewZonedDateTime =
ZonedDateTime.ofInstant(
mostRecentViewTimestamp.toInstant(),
ZONE_ID);
return mostRecentViewZonedDateTime;
}
} catch (SQLException | URISyntaxException ex) {
throw new CannotGetMostRecenetViewTimeException(ex);
}
}
private static void loadJDBCDriverClass() {
try {
Class.forName("com.mysql.cj.jdbc.Driver").newInstance();
} catch (ClassNotFoundException ex) {
LOGGER.log(
Level.SEVERE,
"com.mysql.cj.jdbc.Driver class not found: {0}, " +
"caused by: {1}",
objs(ex, ex.getCause()));
throw new RuntimeException(
"com.mysql.cj.jdbc.Driver not found.",
ex);
} catch (InstantiationException ex) {
LOGGER.log(
Level.SEVERE,
"com.mysql.cj.jdbc.Driver could not be instantiated: {0}," +
" caused by: {1}",
objs(ex, ex.getCause()));
throw new RuntimeException(
"com.mysql.cj.jdbc.Driver could not be instantiated.",
ex);
} catch (IllegalAccessException ex) {
LOGGER.log(
Level.SEVERE,
"com.mysql.cj.jdbc.Driver could not be accessed: {0}, " +
"caused by: {1}",
objs(ex, ex.getCause()));
throw new RuntimeException(
"com.mysql.cj.jdbc.Driver could not be accessed.",
ex);
}
}
static {
loadJDBCDriverClass();
}
private Connection getConnection() throws SQLException, URISyntaxException {
URI databaseURI =
new URI(System.getenv(DB_URL_ENVIRONMENT_VARIABLE_NAME));
String username = databaseURI.getUserInfo().split(":")[0];
String password = databaseURI.getUserInfo().split(":")[1];
String databaseURL =
"jdbc:mysql://" + databaseURI.getHost() + databaseURI.getPath();
return DriverManager.getConnection(databaseURL, username, password);
}
}
com.github.coderodde.weblog.viewcounter.JSONResponseObject.java
package com.github.coderodde.weblog.viewcounter;
/**
* This POJO class type defines a simple object for reporting to the front-end.
*
* @author Rodion "rodde" Efremov
* @version 1.6 (Aug 22, 2021)
* @since 1.6 (Aug 22, 2021)
*/
public final class JSONResponseObject {
public boolean succeeded;
public int numberOfViews;
public String mostRecentViewTime;
}
com.github.coderodde.weblog.viewcounter.sql.SQLDefinitions.java
package com.github.coderodde.weblog.viewcounter.sql;
/**
* This class defines the database table schemas.
*
* @author Rodion "rodde" Efremov
* @version 1.6 (Aug 22, 2021)
* @since 1.6 (Aug 22, 2021)
*/
public final class SQLDefinitions {
/**
* This class defines the structure of the main table.
*/
public static final class ViewTable {
/**
* The name of the main table.
*/
public static final String NAME = "view";
/**
* This class defines the ID column.
*/
public static final class Id {
public static final String NAME = "id";
public static final String TYPE = "INT NOT NULL AUTO_INCREMENT";
}
/**
* This class defines the IP address column.
*/
public static final class IPAddress {
// 16 chars, 15 colons:
public static final int IPV6_ADDRESS_STRING_LENGTH = 16 + 15;
public static final String NAME = "ip_address";
public static final String TYPE =
"VARCHAR(" + IPV6_ADDRESS_STRING_LENGTH + ") NOT NULL";
}
/**
* This class defines the host name column.
*/
public static final class HostName {
public static final int MAXIMUM_LENGTH = 253;
public static final String NAME = "host_name";
public static final String TYPE =
"VARCHAR(" + MAXIMUM_LENGTH + ")";
}
/**
* This class defines the port number column.
*/
public static final class PortNumber {
public static final String NAME = "port";
public static final String TYPE = "INT NOT NULL";
}
public static final class UserName {
public static final String NAME = "user_name";
public static final String TYPE = "VARCHAR(256)";
}
/**
* This class defines the view timestamp.
*/
public static final class ViewTimestamp {
public static final String NAME = "viewed_at";
public static final String TYPE = "TIMESTAMP NOT NULL";
}
}
}
com.github.coderodde.weblog.viewcounter.sql.SQLStatements.java
package com.github.coderodde.weblog.viewcounter.sql;
/**
* This class defines all the SQL statements in the application.
*
* @author Rodion "rodde" Efremov
* @version 1.6 (Aug 22, 2021)
* @since 1.6 (Aug 22, 2021)
*/
public final class SQLStatements {
/**
* The statements for the main table.
*/
public static final class ViewTable {
/**
* The create table statements.
*/
public static final class Create {
/**
* Creates a table for storing the views unless there is one already
* in the database.
*/
public static final String CREATE_MAIN_TABLE =
"CREATE TABLE IF NOT EXISTS " +
SQLDefinitions.ViewTable.NAME +
" (\n" +
SQLDefinitions.ViewTable.Id.NAME + " " +
SQLDefinitions.ViewTable.Id.TYPE + ",\n" +
SQLDefinitions.ViewTable.IPAddress.NAME + " " +
SQLDefinitions.ViewTable.IPAddress.TYPE + ",\n" +
SQLDefinitions.ViewTable.HostName.NAME + " " +
SQLDefinitions.ViewTable.HostName.TYPE + ",\n" +
SQLDefinitions.ViewTable.PortNumber.NAME + " " +
SQLDefinitions.ViewTable.PortNumber.TYPE + ",\n" +
SQLDefinitions.ViewTable.UserName.NAME + " " +
SQLDefinitions.ViewTable.UserName.TYPE + ",\n" +
SQLDefinitions.ViewTable.ViewTimestamp.NAME + " " +
SQLDefinitions.ViewTable.ViewTimestamp.TYPE + ",\n" +
"PRIMARY KEY (" + SQLDefinitions.ViewTable.Id.NAME + ")) " +
"ENGINE=InnoDB DEFAULT CHARSET=utf8 DEFAULT COLLATE " +
"utf8_unicode_ci;;";
}
/**
* The insert data statements.
*/
public static final class Insert {
/**
* Inserts a new view into the database.
*/
public static final String INSERT_VIEW =
"INSERT INTO `" + SQLDefinitions.ViewTable.NAME + "` (" +
SQLDefinitions.ViewTable.IPAddress.NAME + ", " +
SQLDefinitions.ViewTable.HostName.NAME + ", " +
SQLDefinitions.ViewTable.PortNumber.NAME + ", " +
SQLDefinitions.ViewTable.ViewTimestamp.NAME + ") " +
"VALUES (?, ?, ?, ?);";
}
/**
* The select data statements.
*/
public static final class Select {
/**
* Returns the total number of views.
*/
public static final String GET_NUMBER_OF_VIEWS =
"SELECT COUNT(*) FROM `" + SQLDefinitions.ViewTable.NAME +
"`;";
/**
* Returns the most recent view time.
*/
public static final String GET_MOST_RECENT_VIEW_TIME =
"SELECT MAX(" +
SQLDefinitions.ViewTable.ViewTimestamp.NAME + ") FROM " +
SQLDefinitions.ViewTable.NAME + ";";
}
}
}
At the frontend side, I had to do something like this:
viewCounter.js
function processViewCounter() {
let span = document.getElementById("view_count_span");
let xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
renderResults(this.responseText, span);
}
};
xhttp.open("POST", "https://weblog-view-counter.herokuapp.com/countView");
xhttp.send();
}
function getDateString(dateString) {
const event = new Date(dateString);
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', minute: 'numeric', second: 'numeric', hour: 'numeric'};
return event.toLocaleDateString("en-US", options);
}
function renderResults(jsonText, textElement) {
const responseObject = JSON.parse(jsonText);
if (responseObject["succeeded"]) {
const numberOfViews = responseObject["numberOfViews"];
if (numberOfViews) {
textElement.innerHTML = "Total views: " + numberOfViews + ".";
} else {
textElement.innerHTML = "Total views: N/A.";
}
let mostRecentViewTime;
if (responseObject["mostRecentViewTime"]) {
mostRecentViewTime = responseObject["mostRecentViewTime"].replace("[Europe/Helsinki]", "");
mostRecentViewTime = getDateString(mostRecentViewTime);
} else {
mostRecentViewTime = "N/A";
}
textElement.innerHTML += " Last visit time: " + mostRecentViewTime + ".";
} else {
textElement.innerHTML = "Total views: N/A. Last visit time: N/A.";
}
}
Critique request
Please, tell me anything that comes to mind.