10月からスタートしたGoToEatキャンペーン皆さんは使用していますか?
農林水産省 GoToEatキャンペーン
私はちょくちょく使用していたのですが、なんでも巷で「無限くら寿司」というものが流行っているらしい。
GoToEatでもらえるポイントで食事をして、その食事でまたポイントが貰えるので、くら寿司を何回もお得に食べられるといったものです。(モラル的な話はおいておきます)
くら寿司公式GoToEatキャンペーンページ
GoToEatでポイントをもらうためには、くら寿司で一人あたりランチは税込み500円、ディナーは税込み1000円以上食事をする必要があります。
そこで、くら寿司のメニューを該当価格を超えるくらいでランダムで表示される「無限くら寿司ガチャ」を作ってみました。
(アプリ自体は現在止めています)
作ったwebアプリはこちら↓
無限くら寿司ガチャ
今回は「SpringBoot」フレームワークを使用しました。
コード全体はgithubで公開しています↓
https://github.com/yutwoking/eternalKurazushi
今後こういったアプリを作成する際の手順としてこの記事を残しておきます。
アプリ作成環境や使用フレームワークなどの前提
- MacBook Pro (13-inch, 2018, Four Thunderbolt 3 Ports)
- macOS Catalina 10.15.6
- Eclipse_2020-09
- Java11
- gradle
- SpringBoot
- Doma2
大まかな流れ
- ロジック部分の実装
- くら寿司公式サイトからメニューの読み込み部分
- データベースへの格納・摘出部分
- ガチャロジック部分
- SpringBootフレームワーク部分
- build.gradle記述
- ランチャーの作成
- コントローラの作成
- html作成(view作成)
- webアプリ公開(AWS)
1. ロジック部分の実装
くら寿司公式サイトからのメニュー読み込み
まずはメニューを公式サイトから読み込みます。
公式メニューサイトのhtmlを解析する必要があります。
Jsoupというライブラリを使用して解析します。
Jsoup使用方法参考
https://www.codeflow.site/ja/article/java-with-jsoup
解析部分の実装は以下
private static List<MenuModel> loadMenuFromSite(String url) throws IOException{
List<MenuModel> models = new LinkedList<>();
Document doc = Jsoup.connect(url).get();
Elements menusBySection = doc.select(".section-body");
for (Element section : menusBySection) {
String sectionName = section.select(".menu-section-header h3").text();
if (!StringUtils.isEmpty(sectionName)) {
Elements menus = section.select(".menu-list").get(0).select(".menu-item");
for (Element menu : menus) {
String name = menu.select(".menu-name").text();
Elements summary = menu.select(".menu-summary li");
if (summary.size() >2) {
int price = stringToInt(summary.get(0).select("p").get(0).text());
int kcal = stringToInt(summary.get(0).select("p").get(1).text());
String area = summary.get(1).select("p").get(1).text();
boolean takeout = toBoolean(summary.get(2).select("p").get(1).text());
models.add(new MenuModel(name, price, kcal, area, takeout, sectionName));
} else if (summary.size() == 2) {
int price = stringToInt(summary.get(0).select("p").get(0).text());
int kcal = stringToInt(summary.get(0).select("p").get(1).text());
String area = "";
boolean takeout = toBoolean(summary.get(1).select("p").get(1).text());
models.add(new MenuModel(name, price, kcal, area, takeout, sectionName));
}
}
}
}
return models;
}
基本的なJSoupの使用方法としては、
Document doc = Jsoup.connect(url).get();
でurlのhtmlを読み込んで、
Elements elements = doc.select(".section-body");
のようにselectメソッドを使用して、該当要素を抜き出していきます。
ちょっと実装コードは、if文やfor文がネストしちゃっていて見にくいのは反省。。。
データベースへの格納・摘出部分
次にデータベース周りの実装です。
Doma2 というjavaのDBアクセスフレームワークを使用しています。
Doma2公式はこちら
Domaには以下の特徴があります。
・注釈処理を使用して コンパイル時 にコードの生成やコードの検証を行う
・データベース上のカラムの値を振る舞いを持った Java オブジェクトにマッピングできる
・2-way SQL と呼ばれる SQL テンプレートを利用できる
・Java 8 の java.time.LocalDate や java.util.Optional や java.util.stream.Stream を利用できる
・JRE 以外のライブラリへの依存が一切ない
個人的にsqlファイルで管理できるところが好きで良く使用しているフレームワークです。
使用方法は公式がまとまっている&日本語なので参考にすると良いです。
実装コードはここでは割愛。
コード全体はgithubで公開しています↓
https://github.com/yutwoking/eternalKurazushi
ガチャロジック部分
public static List<MenuModelForSearch> getResult(Areas area, boolean isLunch){
List<MenuModelForSearch> result = new ArrayList<>();
//thresholdにランチなら500,ディナーなら1000を格納
int threshold = getThreshold(isLunch);
//candidatesに全メニューを格納
List<MenuModelForSearch> candidates = MenuCaches.getSingleton().getMenuList(area, isLunch);
//取得したメニューの合計金額がthresholdを超えているかチェックし、超えるまでランダムでcandidatesからメニューを加える。
while (isOverThreshold(threshold, result) == false) {
addElement(result, candidates);
}
//最後にランチメニューが含まれているかチェック。ランチメニューが含まれている場合、結果をランチメニューのみにする。
checkIncludeLunchMenu(result);
return result;
}
各メソッドはコード全体を参照してください。
2. SpringBootフレームワーク部分
SpringBootに関しては、以下サイトを参考にしました。
https://qiita.com/gosutesu/items/961b71a95daf3a2bce96
https://qiita.com/opengl-8080/items/eb3bf3b5301bae398cc2
https://note.com/ymzk_jp/n/n272dc9e5c5d3
build.gradleの記述
gradleでSpringBootフレームワークを使用できるようにプラグインやライブラリをbuild.gradleに追記します。
今回の実際のbuild.gradleが↓で//spring-boot追記部分とコメントしてある部分を追記しています。
plugins {
// Apply the java plugin to add support for Java
id 'java'
// Apply the application plugin to add support for building a CLI application
id 'application'
id 'eclipse'
id 'com.diffplug.eclipse.apt' version '3.25.0'
id 'org.springframework.boot' version '2.3.5.RELEASE' //spring-boot追記部分
id 'io.spring.dependency-management' version '1.0.10.RELEASE'//spring-boot追記部分
}
version = '2.26.0-SNAPSHOT'
ext.dependentVersion = '2.24.0'
task copyDomaResources(type: Sync) {
from sourceSets.main.resources.srcDirs
into compileJava.destinationDir
include 'doma.compile.config'
include 'META-INF/**/*.sql'
include 'META-INF/**/*.script'
}
compileJava {
dependsOn copyDomaResources
options.encoding = 'UTF-8'
}
compileTestJava {
options.encoding = 'UTF-8'
options.compilerArgs = ['-proc:none']
}
repositories {
mavenCentral()
mavenLocal()
maven {url 'https://oss.sonatype.org/content/repositories/snapshots/'}
}
dependencies {
// Use JUnit test framework
testImplementation 'junit:junit:4.12'
// https://mvnrepository.com/artifact/org.jsoup/jsoup
compile group: 'org.jsoup', name: 'jsoup', version: '1.13.1'
// https://mvnrepository.com/artifact/org.apache.commons/commons-lang3
compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.11'
annotationProcessor "org.seasar.doma:doma:${dependentVersion}"
implementation "org.seasar.doma:doma:${dependentVersion}"
runtimeOnly 'com.h2database:h2:1.3.175'
// https://mvnrepository.com/artifact/org.postgresql/postgresql
compile group: 'org.postgresql', name: 'postgresql', version: '42.2.8'
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP
compile group: 'com.zaxxer', name: 'HikariCP', version: '3.4.1'
// https://mvnrepository.com/artifact/javax.inject/javax.inject
compile group: 'javax.inject', name: 'javax.inject', version: '1'
// https://mvnrepository.com/artifact/io.vavr/vavr
compile group: 'io.vavr', name: 'vavr', version: '0.10.2'
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf
compile group: 'org.springframework.boot', name: 'spring-boot-starter-thymeleaf', version: '2.3.5.RELEASE'//spring-boot追記部分
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web
compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.3.5.RELEASE'//spring-boot追記部分
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter
compile group: 'org.springframework.boot', name: 'spring-boot-starter', version: '2.3.5.RELEASE'//spring-boot追記部分
}
//spring-bootプロジェクトをサービス化するためにbootJarタスクを追記
bootJar {
launchScript()
}
application {
// Define the main class for the application
mainClassName = 'eternalKurazushi.ServerLuncher'
}
eclipse {
classpath {
file {
whenMerged { classpath ->
classpath.entries.removeAll { it.path == '.apt_generated' }
}
withXml { provider ->
def node = provider.asNode()
// specify output path for .apt_generated
node.appendNode( 'classpathentry', [ kind: 'src', output: 'bin/main', path: '.apt_generated'])
}
}
}
jdt {
javaRuntimeName = 'JavaSE-11'
}
}
ランチャーの作成
@SpringBootApplication
public class ServerLuncher {
public static void main(String[] args) throws Exception {
SpringApplication.run(ServerLuncher.class, args);
LoadMenu.init();
MenuCaches.getSingleton().load();
}
}
@SpringBootApplicationアノテーションを付けて、SpringApplication.runを実装するだけでOKです。
LoadMenu.init();
MenuCaches.getSingleton().load();
この部分はサーバー起動時にメニューを読み込んでDBに格納し、メモリにもメニューを持つようにしています。
実は今回の構成ならば、DBはいらなかったりもしますが、一応今後拡張した場合(おそらく拡張なんてしないけど)も考えてDBも使用しています。
コントローラの作成
@Controller
public class FrontController {
@RequestMapping("/")
public String index() {
return "index";
}
@RequestMapping(value = "/result", method = RequestMethod.POST)
public String getResult(@RequestParam("radio_1") String eatTime, @RequestParam("radio_2") String areaString, Model model) {
if (StringUtils.isEmpty(eatTime) || StringUtils.isEmpty(eatTime)) {
return "error";
}
boolean isLunch = eatTime.equals("ランチ") ? true : false;
Areas area = Areas.東日本;
if (areaString.equals("西日本")) {
area = Areas.西日本;
} else if (areaString.equals("九州・沖縄")) {
area = Areas.九州;
}
List<MenuModelForSearch> gachaResult = Gacha.getResult(area, isLunch);
model.addAttribute("list", gachaResult);
model.addAttribute("sum", getSumString(gachaResult));
model.addAttribute("time", eatTime);
model.addAttribute("area", areaString);
return "result";
}
@Controller アノテーションを使用し、コントローラを実装する。
@RequestMapping アノテーションで対応させるpathを指定する。このへんはjax-rsと似ている。
@RequestParam アノテーションを使用することでhtmlから値を受け取る。
model.addAttribute("list", gachaResult);
addAttributeによって、htmlに値を渡すことができる。
この例だと、listという変数名でgachaResultの値をhtmlに渡している。
return "result";
によって、/resources/templates/【Controller の戻り値】.htmlのテンプレートhtmlが返却される。
この例だと、/resources/templates/result.html が読み込まれて返却される。
html作成(view作成)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>無限くら寿司ガチャ</title>
</head>
<body bgcolor=#99FFFF>
<div style="text-align: center">
<main>
<h1>無限くら寿司ガチャ</h1>
<p th:text="${time} + ' / ' + ${area}" class="description"></p>
<table border="1" align="center">
<tr>
<th>種類</th>
<th>商品名</th>
<th>価格(税抜き)</th>
<th>カロリー</th>
<!--
<th>提供エリア</th>
<th>お持ち帰り</th>
-->
</tr>
<tr th:each="menu : ${list}">
<td th:text="${menu.type}"></td>
<td th:text="${menu.name}"></td>
<td th:text="${menu.price} + '円'"></td>
<td th:text="${menu.kcal} + 'kcal'"></td>
<!--
<td th:text="${menu.area}"></td>
<td th:text="${menu.takeout} ? '可' : '不可'"></td>
-->
</tr>
</table>
<h3>
<p th:text="${sum}" class="sum"></p>
</h3>
<br>
<form method="POST" action="/result">
<input type="hidden" name="radio_1" th:value="${time}"> <input
type="hidden" name="radio_2" th:value="${area}">
<div style="margin: 2rem 0rem">
<input type="submit" value="同じ条件でもう一度ガチャを回す"
style="width: 250px; height: 50px">
</div>
</form>
<form method="GET" action="/">
<div style="margin: 2rem 0rem">
<input type="submit" value="戻る">
</div>
</form>
</main>
</div>
</body>
</html>
th:value="${変数名}"
とすることで、コントローラから受け取った値を使用することができる。
formタグを使用して、inputを作成しnameを指定すれば、コントローラに値を渡すことができる。
3. webアプリ公開(AWS)
今回はAWSを使用してwebアプリを公開しました。
シンプルなアプリなので使用したのは、
- VPC
- EC2
- RDS
くらいです。
初心者はこの書籍がとても丁寧&練習もできるのでおすすめ。
Amazon Web Services 基礎からのネットワーク&サーバー構築 改訂版
最後に
仕事外で何か作成するのは久々でした。
仕事のプログラミングも楽しいけど、プライベートでのプログラミングはまた違った楽しさ(フレームワークの選定や環境作成など)もあるので定期的にスキルアップのためにも行っていきたい。
時間があれば以下に取り組みたい。
- 独自ドメインを取得して設定
- コードのリファクタリング
- webアプリの接続プロトコルのhttps化
- この記事では手順をだいぶささっと書いてしまったのでいくつかの記事に分けて一つ一つまとめてみる。