1 前言&概述
這篇文章是基於此處文章的更新,更新了一些技術棧,更加貼近實際需要,以及修復了若干的錯誤。
這是一個前端Android
+后端Java/Kotlin
通過Servelt
進行后台數據庫(MySQL
)交互的詳細步驟以及源碼實現,技術棧:
Android
基礎- 原生
JDBC
+原生Servlet
Tomcat
+MySQL
(Docker
)
當然現在的很多Java
后端開發都使用了Spring Boot
而不是原生的Servlet
,所以使用Spring Boot
實現的可以筆者的另一篇文章。
盡管基於Spring Boot
實現非常的簡便,但是使用原生的Servlet
更能理解底層的原理。另外本篇文章是偏基礎向的教程,很多步驟都會比較詳細而且附上了圖,好了廢話不說,正文開始。
2 環境
Android Studio 4.1.2
IntelliJ IDEA 2020.3
MySQL 8.0.23
Tomcat 10.0
Docker 20.10.1
- 服務器
CentOS 8.1.1911
3 環境准備
3.1 IDE
准備
官網安裝Android Studio
+IDEA
,這部分就省略了。
3.2 MySQL
3.2.1 安裝概述
這里的MySQL
若無特殊說明指的是MySQL Community
。
首先,在Windows
下,MySQL
提供了exe
安裝包:
macOS
下提供了dmg
安裝包:
可以戳這里下載。
Linux
下一般來說MySQL
安裝有如下方式:
- 軟件包安裝(
apt/apt-get
、yum
、dnf
、pacman
等) - 下載壓縮包安裝
- 源碼編譯安裝
Docker
安裝
其中相對省事的安裝方式為Docker
安裝以及軟件包安裝,其次是壓縮包方式安裝,特別不建議源碼安裝(當然如果喜歡挑戰的話可以參考筆者的一篇編譯安裝8.0.19以及編譯安裝8.0.20)。
3.2.2 安裝開始
這里筆者本地測試選擇的是使用Docker
安裝,步驟可以查看這里。
另外對於服務器,也可以使用Docker
安裝,如果使用軟件包安裝的話,這里以筆者的CentOS8
為例,其他系統的參考如下:
3.2.2.1 下載並安裝
添加倉庫:
sudo yum install https://repo.mysql.com/mysql80-community-release-el8-1.noarch.rpm
禁用默認MySQL
模塊(CentOS8
中會包含一個默認的MySQL
模塊,不禁用的話沒辦法使用上面添加的倉庫安裝):
sudo yum module disable mysql
安裝:
sudo yum install mysql-community-server
3.2.2.2 啟動服務並查看初始化密碼
啟動服務:
systemctl start mysqld
查看臨時密碼:
sudo grep 'temporary password' /var/log/mysqld.log
輸入臨時密碼登錄:
mysql -u root -p
修改密碼:
alter user 'root'@'localhost' identified by 'PASSWORD'
3.2.2.3 創建外部訪問用戶
不建議在Java
中直接訪問root
用戶,一般是新建一個對應權限的用戶並進行訪問,這里就為了方便就省略了。
3.3 Tomcat
3.3.1 本地Tomcat
Tomcat
安裝不難,直接從官網下載即可:
解壓:
tar -zxvf apache-tomcat-10.0.0.tar.gz
進入bin
目錄運行startup.sh
:
cd apache-tomcat-10.0.0/bin
./startup.sh
本地訪問localhost:8080
:
這樣就算成功了。對於Windows
的讀者,可以戳這里下載,解壓步驟類似,解壓后運行startup.bat
即可訪問localhost:8080
。
3.3.2 服務器Tomcat
服務器的話可以直接使用wget
安裝:
wget https://downloads.apache.org/tomcat/tomcat-10/v10.0.0/bin/apache-tomcat-10.0.0.tar.gz
但是這樣速度很慢,建議下載到本地再使用scp
上傳:
scp apache-tomcat-10.0.0.tar.gz username@xxx.xxx.xxx.xxx:/
一樣按照上面的方法解壓后運行startup.sh
,訪問公網IP:8080
即可觀察是否成功。
4 建庫建表
4.1 用戶表
這里使用到的MySQL
腳本如下:
CREATE DATABASE userinfo;
USE userinfo;
CREATE TABLE user
(
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
name CHAR(30) NULL,
password CHAR(30) NULL
)
4.2 導入
mysql -u root -p < user.sql
5 后端部分
因為是比較基礎向的教程,所以先從創建項目開始吧。
5.1 創建項目+導庫
選擇對應Java Enterprise
,默認是選中了其中的Web application
,構建工具默認Maven
,測試工具JUnit
,如果需要Gradle
或Kotlin
的話自行勾選即可:
2020.3
版本的IDEA
相比起以前,更加人性化的添加了選擇庫的功能,默認是選中了Servlet
,需要其他庫的話自行選擇即可。
另外一個要注意的是JavaEE
已經更名為JakartaEE
,因此版本這里可以選擇JakartaEE
:
填上對應包名並選擇位置:
創建完成后,這里筆者遇到了一個錯誤,找不到對應的Servlet
包:
在設置中選擇更新中心倉庫即可:
創建后的目錄如圖所示:
接着添加依賴,用到的依賴包括:
MySQL
Jackson
Lombok
添加到pom.xml
中即可(注意版本,MySQL不同版本可以查看這里):
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.23</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.1</version>
</dependency>
這樣第一步就完成了。
5.2 結構
項目結構如下:
- 持久層操作:
Dao
- 實體類:
User
- 響應體:
ResponseBody
Servlet
層:SignIn
/SignUp
/Test
- 工具類:
DBUtils
啟動類:不需要,因為在Web
服務器中運行
先創建好文件以及目錄:
5.3 DBUtils
原生JDBC
獲取連接工具類:
package com.example.javawebdemo.utils;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DBUtils {
private static Connection connection = null;
public static Connection getConnection() {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
final String url = "jdbc:mysql://127.0.0.1:3306/userinfo";
final String username = "root";
final String password = "123456";
connection = DriverManager.getConnection(url, username, password);
} catch (Exception e) {
e.printStackTrace();
return null;
}
return connection;
}
public static void closeConnection() {
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
重點在這四行:
Class.forName("com.mysql.cj.jdbc.Driver");
String url = "jdbc:mysql://127.0.0.1:3306/userinfo";
String username = "root";
String password = "123456";
根據個人需要修改,注意MySQL8
注冊驅動與舊版的區別,舊版的是:
Class.forName("com.mysql.jdbc.Driver");
5.4 User
三字段+@Getter
:
package com.example.javawebdemo.entity;
import lombok.Getter;
@Getter
public class User {
private final String name;
private final String password;
public User(String name, String password) {
this.name = name;
this.password = password;
}
}
5.5 Dao
數據庫操作層:
package com.example.javawebdemo.dao;
import com.example.javawebdemo.entity.User;
import com.example.javawebdemo.utils.DBUtils;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class Dao {
public boolean select(User user) {
final Connection connection = DBUtils.getConnection();
final String sql = "select * from user where name = ? and password = ?";
try {
final PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, user.getName());
preparedStatement.setString(2, user.getPassword());
ResultSet resultSet = preparedStatement.executeQuery();
return resultSet.next();
} catch (SQLException e) {
e.printStackTrace();
return false;
} finally {
DBUtils.closeConnection();
}
}
public boolean insert(User user) {
final Connection connection = DBUtils.getConnection();
final String sql = "insert into user(name,password) values(?,?)";
try {
final PreparedStatement preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, user.getName());
preparedStatement.setString(2, user.getPassword());
preparedStatement.executeUpdate();
return preparedStatement.getUpdateCount() != 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
} finally {
DBUtils.closeConnection();
}
}
}
兩個操作:
- 查詢:存在該用戶返回
true
,否則false
- 插入:添加用戶
注意插入操作中使用executeUpdate()
進行插入,同時使用getUpdateCount() != 0
判斷插入的結果,而不能直接使用
return preparedStatement.execute();
一般來說:
select
:executeQuery()
,executeQuery()
返回ResultSet
,表示結果集,保存了select
語句的執行結果,配合next()
使用delete
/insert
/update
:使用executeUpdate()
,executeUpdate()
返回的是一個整數,表示受影響的行數,即delete
/insert
/update
修改的行數,對於drop
/create
操作返回0
create
/drop
:使用execute()
,execute()
的返回值是這樣的,如果第一個結果是ResultSet
對象,則返回true
,如果第一個結果是更新計數或者沒有結果則返回false
所以在這個例子中
return preparedStatement.execute();
肯定返回false
,不能直接判斷是否插入成功。
5.6 響應體
添加一個響應體類方便設置返回碼以及數據:
package com.example.javawebdemo.response;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class ResponseBody{
private Object data;
private int code;
}
5.7 Servlet
SingIn
類用於處理登錄,調用JDBC
查看數據庫是否有對應的用戶SignUp
類用於處理注冊,把User
添加到數據庫中Test
為測試Servlet
,返回固定字符串
先上SignIn.java
package com.example.javawebdemo.servlet;
import com.example.javawebdemo.dao.Dao;
import com.example.javawebdemo.entity.User;
import com.example.javawebdemo.response.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/sign/in")
public class SignIn extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.setCharacterEncoding("utf-8");
resp.setCharacterEncoding("utf-8");
resp.setContentType("application/json;charset=utf-8");
String name = req.getParameter("name");
String password = req.getParameter("password");
Dao dao = new Dao();
User user = new User(name,password);
ObjectMapper mapper = new ObjectMapper();
ResponseBody body = new ResponseBody();
if (dao.select(user)) {
body.setCode(200);
body.setData("success");
} else {
body.setCode(404);
body.setData("failed");
}
mapper.writeValue(resp.getWriter(), body);
}
}
注意點:
@WebServlet
:定義Servlet
(不加這個注解也是可以的但是需要在web.xml
中手工定義Servlet
),默認的屬性為value
,表示Servlet
路徑- 編碼:
HttpServletRequest
/HttpServletResponse
均設置UTF8
(雖然在這個例子中並不是必要的因為沒有中文字符) - 獲取參數:
request.getParameter
,從請求中獲取參數,傳入的參數是鍵值 - 寫響應體:利用
Jackson
,將response.getWriter
以及響應體傳入,接着交給mapper.writeValue
進行寫響應體
下面是SignUp.java
,大部分代碼類似:
package com.example.javawebdemo.servlet;
import com.example.javawebdemo.dao.Dao;
import com.example.javawebdemo.entity.User;
import com.example.javawebdemo.response.ResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/sign/up")
public class SignUp extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.setCharacterEncoding("utf-8");
resp.setCharacterEncoding("utf-8");
resp.setContentType("application/json;charset=utf-8");
String name = req.getParameter("name");
String password = req.getParameter("password");
Dao dao = new Dao();
User user = new User(name,password);
ResponseBody body = new ResponseBody();
ObjectMapper mapper = new ObjectMapper();
if (dao.insert(user)) {
body.setCode(200);
body.setData("success");
} else {
body.setCode(500);
body.setData("failed");
}
mapper.writeValue(resp.getWriter(), body);
}
}
測試Servlet
:
package com.example.javawebdemo.servlet;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/test")
public class Test extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.getWriter().print("Hello, Java Web");
}
}
5.8 運行
需要借助Tomcat
運行,選擇運行配置中的Tomcat Server
:
設置Tomcat
根目錄:
接着在Deployment
選擇+
后,選擇第二個帶exploded
的(當然第一個也不是不可以,不過第一個一般是發布到遠程版本,是以WAR
形式的,而第二個是直接將所有文件以當前目錄形式復制到webapps
下,並且在調試模式下支持熱部署):
另外可以把這個路徑修改為一個比較簡單的路徑,方便操作:
調試(運行不能進行熱部署):
訪問localhost:8080/demo
(IDEA
應該會自動打開)會出現如下頁面:
訪問路徑下的test
會出現:
這樣后端就處理完成了,下面處理Android
端。
6 Android
端
6.1 新建項目
6.2 依賴/權限
依賴如下:
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.12.1'
在build.gradle
中加上即可,另外,再加上:
buildFeatures{
viewBinding = true
}
viewBinding
就是視圖綁定功能,以前是通過findViewById
獲取對應的組件,后面就有了Butter Knife,到現在Butter Knife
過期了,推薦使用view binding
。
另外在AndroidManifest.xml
中加入網絡權限:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
還需要添加HTTP
的支持,因為這是一個示例Demo
就不上HTTPS
了,但是目前Android
的版本默認不支持,因此需要在<application>
添加:
android:usesCleartextTraffic="true"
6.3 項目結構
四個文件:
MainActivity
:核心Activity
NetworkSettings
:請求URL
,常量NetworkThread
:網絡請求線程ResponseBody
:請求體
6.4 ResponseBody
package com.example.androiddemo;
public class ResponseBody {
private int code;
private Object data;
public int getCode() {
return code;
}
public Object getData() {
return data;
}
}
響應體,一個返回碼字段+一個數據字段。
6.5 NetworkSettings
package com.example.androiddemo;
public class NetworkSettings {
private static final String HOST = "192.168.43.35";
private static final String PORT = "8080";
public static final String SIGN_IN = "http://"+ HOST +":"+PORT + "/demo/sign/in";
public static final String SIGN_UP = "http://"+ HOST +":"+PORT + "/demo/sign/up";
}
請求URL
常量,HOST
請修改為自己的內網IP
,注意不能使用localhost
/127.0.0.1
。
可以使用ip addr
/ifconfig
/ipconfig
等查看自己的內網IP
:
6.6 NetworkThread
package com.example.androiddemo;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Callable;
public class NetworkThread implements Callable<String> {
private final String name;
private final String password;
private final String url;
public NetworkThread(String name, String password, String url) {
this.name = name;
this.password = password;
this.url = url;
}
@Override
public String call(){
try {
//開啟連接
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
//拼接數據
String data = "name="+ URLEncoder.encode(name, StandardCharsets.UTF_8.toString())+"&password="+URLEncoder.encode(password,StandardCharsets.UTF_8.toString());
//設置請求方法
connection.setRequestMethod("POST");
//允許輸入輸出
connection.setDoInput(true);
connection.setDoOutput(true);
//寫數據(也就是發送數據)
connection.getOutputStream().write(data.getBytes(StandardCharsets.UTF_8));
byte [] bytes = new byte[1024];
//獲取返回的數據
int len = connection.getInputStream().read(bytes);
return new String(bytes,0,len,StandardCharsets.UTF_8);
} catch (IOException e) {
e.printStackTrace();
return "";
}
}
}
發送網絡請求的線程類,由於是異步操作的線程,實現了Callable<String>
接口,表示返回的是String
類型的數據,主線程可通過get()
阻塞獲取返回值。
6.7 MainActivity
package com.example.androiddemo;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.example.androiddemo.databinding.ActivityMainBinding;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.FutureTask;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private final ObjectMapper mapper = new ObjectMapper();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
}
public void signIn(View view){
String name = binding.editTextName.getText().toString();
String password = binding.editTextPassword.getText().toString();
FutureTask<String> signInTask = new FutureTask<>(new NetworkThread(name,password,NetworkSettings.SIGN_IN));
Thread thread = new Thread(signInTask);
thread.start();
try{
//get獲取線程返回值,通過ObjectMapper反序列化為ResponseBody
ResponseBody body = mapper.readValue(signInTask.get(),ResponseBody.class);
//根據返回碼確定提示信息
Toast.makeText(getApplicationContext(),body.getCode() == 200 ? "登錄成功" : "登錄失敗",Toast.LENGTH_SHORT).show();
}catch (Exception e){
e.printStackTrace();
}
}
public void signUp(View view){
String name = binding.editTextName.getText().toString();
String password = binding.editTextPassword.getText().toString();
FutureTask<String> signUpTask = new FutureTask<>(new NetworkThread(name,password,NetworkSettings.SIGN_UP));
Thread thread = new Thread(signUpTask);
thread.start();
try{
ResponseBody body = mapper.readValue(signUpTask.get(),ResponseBody.class);
Toast.makeText(getApplicationContext(),body.getCode() == 200 ? "注冊成功" : "注冊失敗",Toast.LENGTH_SHORT).show();
}catch (Exception e){
e.printStackTrace();
}
}
}
說一下viewBinding
,在onCreate
中:
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
通過ActivityMainBinding
的靜態方法獲取binding
,注意ActivityMainBinding
這個類的類名不是固定的,比如Android官方的文檔中就是:
6.8 資源文件
兩個:
activity_main.xml
strings.xml
分別如下,不細說了:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textViewName"
android:layout_width="45dp"
android:layout_height="38dp"
android:layout_marginStart="24dp"
android:layout_marginTop="92dp"
android:text="@string/name"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/editTextName"
android:layout_width="300dp"
android:layout_height="40dp"
android:layout_marginStart="64dp"
android:layout_marginTop="84dp"
android:autofillHints=""
android:inputType="text"
app:layout_constraintLeft_toLeftOf="@id/textViewName"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="LabelFor" />
<TextView
android:id="@+id/textViewPassword"
android:layout_width="45dp"
android:layout_height="36dp"
android:layout_marginStart="24dp"
android:layout_marginTop="72dp"
android:text="@string/password"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="@id/textViewName" />
<EditText
android:id="@+id/editTextPassword"
android:layout_width="300dp"
android:layout_height="40dp"
android:layout_marginStart="64dp"
android:layout_marginTop="72dp"
android:autofillHints=""
android:inputType="textPassword"
app:layout_constraintLeft_toLeftOf="@id/textViewPassword"
app:layout_constraintTop_toTopOf="@id/editTextName"
tools:ignore="LabelFor" />
<Button
android:id="@+id/buttonSignUp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="56dp"
android:layout_marginTop="32dp"
android:onClick="signUp"
android:text="@string/signUp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textViewPassword"
tools:ignore="ButtonStyle" />
<Button
android:id="@+id/buttonSignIn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="36dp"
android:layout_marginEnd="52dp"
android:onClick="signIn"
android:text="@string/signIn"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/editTextPassword"
tools:ignore="ButtonStyle" />
</androidx.constraintlayout.widget.ConstraintLayout>
<resources>
<string name="app_name">AndroidDemo</string>
<string name="name">用戶名</string>
<string name="password">密碼</string>
<string name="signUp">注冊</string>
<string name="signIn">登錄</string>
</resources>
7 測試
7.1 本地測試
首先運行Java Web
端,應該會自動打開如下界面:
附加test
后:
運行Android
端,先輸入一個不存在的用戶名或密碼,提示登錄失敗,再進行注冊,然后登錄成功:
同時查看后端數據庫如下:
7.2 部署測試
首先確保本地數據庫的用戶名與密碼與服務器的用戶名與密碼一致。同時存在對應的表以及庫
部署Java Web
端之前先在pom.xml
中加入一個<finalName>
:
在右側的工具欄先選擇clean
,再選擇編譯,最后選擇打包:
之所以這樣做是因為如果更新了文件,打包不會把文件更新再打包進去,因此需要先清除原來的字節碼文件,再編譯最后打包。
完成后會出現一個demo.war
位於target
下:
scp
(或其他工具)上傳到服務器,並移動到Tomcat
的webapps
(為了方便說明以下假設服務器的IP
為8.8.8.8
):
scp demo.war 8.8.8.8/xxx
# 通過ssh連接服務器后
cp demo.war /usr/local/tomcat/webapps
啟動Tomcat
:
cd /usr/local/tomcat/bin
./startup.sh
啟動后就可以看見在webapps
下多了一個demo
的文件夾:
訪問8.8.8.8/demo
看到本地測試的頁面就可以了。接着修改Android
端的NetworkSettings
中的HOST
為8.8.8.8
,如果沒問題的話就能正常訪問了:
服務器數據庫:
8 注意事項
注意事項比較瑣碎而且有點多,因此另開了一篇博客,戳這里。
如果還有其他問題歡迎留言。
9 源碼
提供了Java
+Kotlin
兩種語言實現:
如果覺得文章好看,歡迎點贊。
同時歡迎關注微信公眾號:氷泠之路。