简易MVC框架实现

简易MVC框架实现

一、MVC框架的初衷

通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点:

  • Servlet实现业务逻辑;
  • JSP实现页面展示逻辑。

但是,直接把MVC搭在Servlet和JSP之上还是不太好,原因如下:

  • Servlet提供的接口仍然偏底层,需要实现Servlet调用相关接口;
  • JSP对页面开发不友好,更好的替代品是模板引擎
  • 业务逻辑最好由纯粹的Java类实现,而不是强迫继承自Servlet。

能不能通过普通的Java类实现MVC的Controller?类似下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserController {
@GetMapping("/signin")
public ModelAndView signin() {
...
}

@PostMapping("/signin")
public ModelAndView doSignin(SignInBean bean) {
...
}

@GetMapping("/signout")
public ModelAndView signout(HttpSession session) {
...
}
}

上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是ModelAndView,它包含一个View的路径以及一个Model,这样,再由MVC框架处理后返回给浏览器。

如果是GET请求,我们希望MVC框架能直接把URL参数按方法参数对应起来然后传入:

1
2
3
4
@GetMapping("/hello")
public ModelAndView hello(String name) {
...
}

如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:

1
2
3
4
@PostMapping("/signin")
public ModelAndView doSignin(SignInBean bean) {
...
}

为了增加灵活性,如果Controller的方法在处理请求时需要访问HttpServletRequestHttpServletResponseHttpSession这些实例时,只要方法参数有定义,就可以自动传入:

1
2
3
4
@GetMapping("/signout")
public ModelAndView signout(HttpSession session) {
...
}

以上就是我们在设计MVC框架时,上层代码所需要的一切信息。

二、设计MVC框架

如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不要求实现特定接口,只需返回ModelAndView对象,该对象包含一个View和一个Model。实际上View就是模板的路径,而Model可以用一个Map<String, Object>表示,因此,ModelAndView定义非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ModelAndView {

Map<String, Object> model;
String view;

public ModelAndView(String view) {
this.view = view;
this.model = Map.of();
}

public ModelAndView(String view, String name, Object value) {
this.view = view;
this.model = new HashMap<>();
this.model.put(name, value);
}

public ModelAndView(String view, Map<String, Object> model) {
this.view = view;
this.model = new HashMap<>(model);
}
}

比较复杂的是我们需要在MVC框架中创建一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,它总是映射到/,然后,根据不同的Controller的方法定义的@GetMapping@PostMapping的Path决定调用哪个方法,最后,获得方法返回的ModelAndView后,渲染模板,写入HttpServletResponse,即完成了整个MVC的处理。

这个MVC的架构如下:

image-20231129145956772

其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。

三、实现MVC框架

我们来看看如何编写最复杂的DispatcherServlet。首先,我们需要存储请求路径到某个具体方法的映射:

1
2
3
4
5
6
7
8
9
@WebServlet(urlPatterns = "/")
public class DispatcherServlet extends HttpServlet {
private Map<String, GetDispatcher> getMappings = new HashMap<>();
private Map<String, PostDispatcher> postMappings = new HashMap<>();
// TODO: 可指定package并自动扫描:
private List<Class<?>> controllers = List.of(IndexController.class, UserController.class);
// 渲染模板引擎,后面会使用到
private ViewEngine viewEngine;
}

处理一个GET请求是通过GetDispatcher对象完成的,它需要如下信息:

1
2
3
4
5
6
7
8
9
10
abstract class AbstractDispatcher {
public abstract ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) throws IOException, ReflectiveOperationException;
}

class GetDispatcher extends AbstractDispatcher{
Object instance; // Controller实例
Method method; // Controller方法
String[] parameterNames; // 方法参数名称
Class<?>[] parameterClasses; // 方法参数类型
}

有了以上信息,就可以定义invoke()来处理真正的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) throws IOException, ReflectiveOperationException {
Object[] arguments = new Object[parameterClasses.length];
for (int i = 0; i < parameterClasses.length; i++) {
String parameterName = parameterNames[i];
Class<?> parameterClass = parameterClasses[i];
if (parameterClass == HttpServletRequest.class) {
arguments[i] = request;
} else if (parameterClass == HttpServletResponse.class) {
arguments[i] = response;
} else if (parameterClass == HttpSession.class) {
arguments[i] = request.getSession();
} else if (parameterClass == int.class) {
arguments[i] = Integer.valueOf(getOrDefault(request, parameterName, "0"));
} else if (parameterClass == long.class) {
arguments[i] = Long.valueOf(getOrDefault(request, parameterName, "0"));
} else if (parameterClass == boolean.class) {
arguments[i] = Boolean.valueOf(getOrDefault(request, parameterName, "false"));
} else if (parameterClass == String.class) {
arguments[i] = getOrDefault(request, parameterName, "");
} else {
throw new RuntimeException("Missing handler for type: " + parameterClass);
}
}
return (ModelAndView) this.method.invoke(this.instance, arguments);
}

private String getOrDefault(HttpServletRequest request, String name, String defaultValue) {
String s = request.getParameter(name);
return s == null ? defaultValue : s;
}

上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法后返回结果。(GET请求默认只支持int,long,boolean,String,HttpServlet,HttpServletResponse,HttpSession这几种参数类型)

类似的,PostDispatcher需要如下信息:

1
2
3
4
5
6
class PostDispatcher extends AbstractDispatcher{
Object instance; // Controller实例
Method method; // Controller方法
Class<?>[] parameterClasses; // 方法参数类型
ObjectMapper objectMapper; // JSON映射
}

和GET请求不同,POST请求严格地来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,==只支持==JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。(POST请求默认只支持HttpServlet,HttpServletResponse,HttpSession这三种参数类型或者单个JavaBean类型的参数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response)
throws IOException, ReflectiveOperationException {
Object[] arguments = new Object[parameterClasses.length];
for (int i = 0; i < parameterClasses.length; i++) {
Class<?> parameterClass = parameterClasses[i];
if (parameterClass == HttpServletRequest.class) {
arguments[i] = request;
} else if (parameterClass == HttpServletResponse.class) {
arguments[i] = response;
} else if (parameterClass == HttpSession.class) {
arguments[i] = request.getSession();
} else {
// 读取JSON并解析为JavaBean:
BufferedReader reader = request.getReader();
arguments[i] = this.objectMapper.readValue(reader, parameterClass);
}
}
return (ModelAndView) this.method.invoke(instance, arguments);
}

最后,我们来实现整个DispatcherServlet的处理流程,以doGet()doPost()为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class DispatcherServlet extends HttpServlet {
// init方法下面会说到,主要完成getMappings和postMappings以及viewEngine的初始化
...

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
process(req, resp, this.getMappings);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
process(req, resp, this.postMappings);
}


private void process(HttpServletRequest req, HttpServletResponse resp, Map<String, ? extends AbstractDispatcher> dispatcherMap) throws ServletException, IOException {
resp.setContentType("text/html");
resp.setCharacterEncoding("UTF-8");
// ContextPath是Web应用上下文,指Web应用程序在Web服务器上的部署路径,它是从Web服务器的根目录到应用程序的根目录的相对路径,例如,如果Web应用程序部署在Tomcat服务器上,并且应用程序名称为myapp,则ContextPath将是"/myapp"
String path = req.getRequestURI().substring(req.getContextPath().length());
// 根据路径查找GetDispatcher或PostDispatcher:
AbstractDispatcher dispatcher = dispatcherMap.get(path);
if (dispatcher == null) {
// 未找到返回404:
resp.sendError(404);
return;
}
ModelAndView mv = null;
try {
// 调用匹配的Controller方法获得返回值:
mv = dispatcher.invoke(req, resp);
} catch (ReflectiveOperationException e) {
throw new ServletException(e);
}
// 允许Controller方法返回null,表示Controller内部已自行处理完毕
if (mv == null) {
return;
}
// 允许Controller方法返回以redirect:开头的view名称,表示一个重定向
if (mv.view.startsWith("redirect:")) {
resp.sendRedirect(mv.view.substring(9));
return;
}
// 将模板引擎渲染的内容写入响应:
PrintWriter pw = resp.getWriter();
this.viewEngine.render(mv, pw);
pw.flush();
}

这里有几个小改进:

  • 允许Controller方法返回null,表示内部已自行处理完毕;
  • 允许Controller方法返回以redirect:开头的view名称,表示一个重定向。

这样使得上层代码编写更灵活。例如,一个显示用户资料的请求可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/user/profile")
public ModelAndView profile(HttpServletResponse response, HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
// 未登录,跳转到登录页:
return new ModelAndView("redirect:/signin");
}
if (!user.isManager()) {
// 权限不够,返回403:
response.sendError(403);
return null;
}
return new ModelAndView("/profile.html", Map.of("user", user));
}

最后一步是在DispatcherServletinit()方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 当Servlet容器创建当前Servlet实例后,会自动调用init(ServletConfig)方法
*/
@Override
public void init() throws ServletException {
logger.info("init {}...", getClass().getSimpleName());
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 依次处理每个Controller:
for (Class<?> controllerClass : controllers) {
try {
Object controllerInstance = controllerClass.getConstructor().newInstance();
// 依次处理每个Method:
for (Method method : controllerClass.getMethods()) {
if (method.getAnnotation(GetMapping.class) != null) {
// 处理@GetMapping:
if (method.getReturnType() != ModelAndView.class && method.getReturnType() != void.class) {
throw new UnsupportedOperationException(
"Unsupported return type: " + method.getReturnType() + " for method: " + method);
}
for (Class<?> parameterClass : method.getParameterTypes()) {
// GET请求方法的参数有限制,只支持int,long,boolean,String,HttpServlet,HttpServletResponse,HttpSession这几种参数类型
if (!supportedGetParameterTypes.contains(parameterClass)) {
throw new UnsupportedOperationException(
"Unsupported parameter type: " + parameterClass + " for method: " + method);
}
}
String[] parameterNames = Arrays.stream(method.getParameters())
.map(p -> p.getName()).toArray(String[]::new);
String path = method.getAnnotation(GetMapping.class).value();
logger.info("Found GET: {} => {}", path, method);
this.getMappings.put(path, new GetDispatcher(controllerInstance, method, parameterNames,method.getParameterTypes()));
} else if (method.getAnnotation(PostMapping.class) != null) {
// 处理@PostMapping:
if (method.getReturnType() != ModelAndView.class && method.getReturnType() != void.class) {
throw new UnsupportedOperationException(
"Unsupported return type: " + method.getReturnType() + " for method: " + method);
}
Class<?> requestBodyClass = null;
for (Class<?> parameterClass : method.getParameterTypes()) {
// POST请求方法的参数有限制,只支持HttpServlet,HttpServletResponse,HttpSession这三种参数类型或者单个JavaBean类型的参数
if (!supportedPostParameterTypes.contains(parameterClass)) {
if (requestBodyClass == null) {
requestBodyClass = parameterClass;
} else {
// POST方法仅支持单个JavaBean参数类型
throw new UnsupportedOperationException("Unsupported duplicate request body type: "+ parameterClass + " for method: " + method);
}
}
}
String path = method.getAnnotation(PostMapping.class).value();
logger.info("Found POST: {} => {}", path, method);
this.postMappings.put(path, new PostDispatcher(controllerInstance, method, method.getParameterTypes(), objectMapper));
}
}
} catch (ReflectiveOperationException e) {
throw new ServletException(e);
}
}
// 创建ViewEngine:
this.viewEngine = new ViewEngine(getServletContext());
}

private static final Set<Class<?>> supportedGetParameterTypes = Set.of(int.class, long.class, boolean.class, String.class, HttpServletRequest.class, HttpServletResponse.class, HttpSession.class);

private static final Set<Class<?>> supportedPostParameterTypes = Set.of(HttpServletRequest.class,HttpServletResponse.class, HttpSession.class);

四、渲染的简单实现

其实ViewEngine非常简单,只需要实现一个简单的render()方法:

1
2
3
4
5
6
7
8
9
10
public class ViewEngine {
public void render(ModelAndView mv, Writer writer) throws IOException {
String view = mv.view;
Map<String, Object> model = mv.model;
// 根据view找到模板文件:
Template template = getTemplateByPath(view);
// 渲染并写入writer:
template.write(writer, model);
}
}

Java有很多开源的模板引擎,他们的用法都大同小异。这里我们推荐一个使用Jinja语法的模板引擎Pebble,它的特点是语法简单,支持模板继承,编写出来的模板类似:

1
2
3
4
5
6
7
8
9
<html>
<body>
<ul>
{% for user in users %}
<li><a href="{{ user.url }}">{{ user.username }}</a></li>
{% endfor %}
</ul>
</body>
</html>

即变量用{{ xxx }}表示,控制语句用{% xxx %}表示。使用Pebble渲染只需要如下几行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ViewEngine {
private final PebbleEngine engine;

public ViewEngine(ServletContext servletContext) {
// 定义一个ServletLoader用于加载模板:
ServletLoader loader = new ServletLoader(servletContext);
// 模板编码:
loader.setCharset("UTF-8");
// 模板前缀,这里默认模板必须放在`/WEB-INF/templates`目录:
loader.setPrefix("/WEB-INF/templates");
// 模板后缀:
loader.setSuffix("");
// 创建Pebble实例:
this.engine = new PebbleEngine.Builder()
.autoEscaping(true) // 默认打开HTML字符转义,防止XSS攻击
.cacheActive(false) // 禁用缓存使得每次修改模板可以立刻看到效果
.loader(loader).build();
}

public void render(ModelAndView mv, Writer writer) throws IOException {
// 查找模板:
PebbleTemplate template = this.engine.getTemplate(mv.view);
// 渲染:
template.evaluate(writer, mv.model);
}
}

最后我们来看看整个工程的结构:

image-20231003200314085

其中,framework包是MVC的框架,完全可以单独编译后作为一个Maven依赖引入,controller包才是我们需要编写的业务逻辑。

我们还硬性规定模板必须放在webapp/WEB-INF/templates目录下,静态文件必须放在webapp/static目录下,因此,为了便于开发,我们还顺带实现一个FileServlet来处理静态文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@WebServlet(urlPatterns = { "/favicon.ico", "/static/*" })
public class FileServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 读取当前请求路径:
ServletContext ctx = req.getServletContext();
// RequestURI包含ContextPath,需要去掉:
String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());
// 获取真实文件路径:
String filepath = ctx.getRealPath(urlPath);
if (filepath == null) {
// 无法获取到路径:
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Path path = Paths.get(filepath);
if (!path.toFile().isFile()) {
// 文件不存在:
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 根据文件名猜测Content-Type:
String mime = Files.probeContentType(path);
if (mime == null) {
mime = "application/octet-stream";
}
resp.setContentType(mime);
// 读取文件并写入Response:
OutputStream output = resp.getOutputStream();
try (InputStream input = new BufferedInputStream(new FileInputStream(filepath))) {
input.transferTo(output);
}
output.flush();
}
}

运行代码,在浏览器中输入URLhttp://localhost:8080/hello?name=Bob可以看到如下页面:

image-20231003200425071

image-20231003200521806

为了把方法参数的名称编译到class文件中,以便处理@GetMapping时使用,我们需要打开编译器的一个参数在Idea中选择Preferences-Build, Execution, Deployment-Compiler-Java Compiler-Additional command line parameters,填入-parameters;在Maven的pom.xml添加一段配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<project ...>
<modelVersion>4.0.0</modelVersion>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

五、简单的案例测试

经过上面我们对MVC框架的实现,下面我们构想一个常见的个人信息查看场景,如果用户未登录的话需要先登录才能查看,下面我们利用前面实现的MVC框架编写相关的登录、登出、首页、查看个人信息的Controller。

模型层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class User {

public String email;
public String password;
public String name;
public String description;

public User() {
}

public User(String email, String password, String name, String description) {
this.email = email;
this.password = password;
this.name = name;
this.description = description;
}
}

对于登录接口我们专门设计了相应的JavaBean,非常简单就是邮箱和密码两个字段:

1
2
3
4
public class SignInBean {
public String email;
public String password;
}

控制器层

下面是处理首页跳转的控制器IndexController

1
2
3
4
5
6
7
public class IndexController {
@GetMapping("/")
public ModelAndView index(HttpSession session) {
User user = (User) session.getAttribute("user");
return new ModelAndView("/index.html", "user", user);
}
}

下面是处理用户信息的核心控制器UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
ublic class UserController {
// Map模拟的用户数据库
private Map<String, User> userDatabase = new HashMap<>() {
{
List<User> users = List.of( //
new User("bob@example.com", "bob123", "Bob", "This is bob."),
new User("tom@example.com", "tomcat", "Tom", "This is tom."));
users.forEach(user -> {
put(user.email, user);
});
}
};

// 登录跳转接口
@GetMapping("/signin")
public ModelAndView signin() {
return new ModelAndView("/signin.html");
}

// 登录处理接口
@PostMapping("/signin")
public ModelAndView doSignin(SignInBean bean, HttpServletResponse response, HttpSession session) throws IOException {
User user = userDatabase.get(bean.email);
if (user == null || !user.password.equals(bean.password)) {
response.setContentType("application/json");
PrintWriter pw = response.getWriter();
pw.write("{\"error\":\"Bad email or password\"}");
pw.flush();
} else {
session.setAttribute("user", user);
response.setContentType("application/json");
PrintWriter pw = response.getWriter();
pw.write("{\"result\":true}");
pw.flush();
}
// 已经处理完毕,返回null
return null;
}

// 登出接口,需要重定向到首页
@GetMapping("/signout")
public ModelAndView signout(HttpSession session) {
session.removeAttribute("user");
return new ModelAndView("redirect:/");
}

// 登陆成功后可以查看用户个人信息
@GetMapping("/user/profile")
public ModelAndView profile(HttpSession session) {
// 登录逻辑在AuthFilter中处理
User user = (User) session.getAttribute("user");
return new ModelAndView("/profile.html", "user", user);
}
}

在上面的控制器方法中我们并没有对用户登录逻辑在每个控制器方法中做判断,因为我们考虑后续可能有很多的业务都需要首先保证用户登录成功,总不能所有相关的控制器方法都重复一遍登录逻辑判断吧,因此我们使用过滤器在控制器前统一处理,具体包括登录校验过滤器AuthFilter、字符编码过滤器EncodingFilter、日志过滤器LogFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@WebFilter(urlPatterns = "/user/*")
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("AuthFilter: check authentication");
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
if (req.getSession().getAttribute("user") == null) {
// 用户未登录,拦截请求,自动跳转到登录页
System.out.println("AuthFilter: not signin!");
resp.sendRedirect("/signin");
} else {
// 已登录,继续处理
chain.doFilter(request, response);
}
}
}

@WebFilter(urlPatterns = "/*")
public class EncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("EncodingFilter:doFilter");
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, response);
}
}

@WebFilter(urlPatterns = "/*")
public class LogFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("LogFilter: process " + ((HttpServletRequest) request).getRequestURI());
chain.doFilter(request, response);
}
}

image-20231129155245419

视图层

至于模板文件如_base.htmlindex.htmlprofile.htmlsignin.html的内容我主要贴一些重要内容,不然显得有些啰嗦:

  • _base.html

image-20231129155455324

  • index.html

image-20231129155556405

  • profile.html

image-20231129155647959

  • signin.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{% extends "_base.html" %}

{% block main %}

<script>
$(function () {
$('#signinForm').submit(function (e) {
e.preventDefault();
var data = {
email: $('#email').val(),
password: $('#password').val()
};
$.ajax({
type: 'POST',
url: '/signin',
data: JSON.stringify(data),
success: function (resp) {
if (resp.error) {
$('#error').text(resp.error);
} else {
location.assign('/');
}
},
contentType: 'application/json',
dataType: 'json'
});
});
});
</script>

<form id="signinForm">
<div class="form-group">
<p id="error" class="text-danger"></p>
</div>
<div class="form-group">
<label>Email</label>
<input id="email" type="email" class="form-control" placeholder="Email">
</div>
<div class="form-group">
<label>Password</label>
<input id="password" type="password" class="form-control" placeholder="Password">
</div>
<button type="submit" class="btn btn-outline-primary">Submit</button>
</form>

{% endblock %}

演示效果

image-20231129155832267

启动项目后,访问首页,此时用户未登录:

image-20231129160027562

点击登录,输入模拟数据库中的用户名和密码:

image-20231129160125574

登录成功,session中保存用户信息,重定向到首页会显示用户名称:

image-20231129160140275

点击查看用户信息,先显示用户的各项信息:

image-20231129160228632

点击登出,用户信息会从session中清除,并再次重定向到首页:

image-20231129160254140