手把手撸一个wiki系统(3):前后端交互

崩天的勾玉
崩天的勾玉
崩天的勾玉
65
文章
4
评论
2021年6月9日20:56:33
评论
36 14189字

上一节(手把手撸一个wiki系统(2):前端vue整合)我们完成了前端部分vue-cli的搭建,这节我们完成前后端的交互。

集成HTTP库axios

Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。

特性

  • 从浏览器中创建 XMLHttpRequests
  • 从 node.js 创建 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换 JSON 数据
  • 客户端支持防御 XSRF

在web目录下安装:

yarn add axios

可以看到package.json和yarn.lock都发生了变化。(提交至commit 3-1)

跨域处理

后端端口现在是8081,前端是8080,前端发送的请求不被后端信任 No 'Access-Control-Allow-Origin' header is present on the requested resource.当一个请求 url 的协议、域名、端口三者之间任意一个与当前页面 url 不同即为跨域

在config包下新建CorsConfig.java,实现webMvcConfigurer类,重写addCorsMapping方法

/**
 * 这里我们的CORSConfig配置类继承了WebMvcConfigurer父类并且重写了addCorsMappings方法,我们来简单介绍下我们的配置信息
 * allowedOrigins:允许设置的请求域名访问我们的跨域资源,可以固定单条或者多条内容,如:"http://www.baidu.com",只有百度可以访问我们的跨域资源。
 * addMapping:配置可以被跨域的路径,可以任意配置,可以具体到直接请求路径。
 * allowedMethods:设置允许的请求方法类型访问该跨域资源服务器,如:POST、GET、PUT、OPTIONS、DELETE等。
 * allowedHeaders:允许所有的请求header访问,可以自定义设置任意请求头信息,如:"X-YYYY-TOKEN"
 * allowCredentials: 是否允许请求带有验证信息,用户是否可以发送、处理 cookie
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedHeaders(CorsConfiguration.ALL)
                .allowedMethods(CorsConfiguration.ALL)
                .allowCredentials(true)
                .maxAge(3600);  //一小时内不再预检(发送options请求看接口是否正常)
    }
}

此处介绍了多种跨域的处理方式:https://www.cnblogs.com/sueyyyy/p/10129575.html

然后在Home.vue里用setup(相当于方法)来请求后端。

export default defineComponent({
  name:'Home',
  setup(){
  console.log("log"),
  axios.get("http://localhost:8081/ebook/search?name=y").then((response) => {
    console.log(response);
  })
  }
})

前端电子书列表显示

将list列表组件选择一个样式拷过来,Home.vue如下:

<template>
  <a-layout>
    <a-layout-sider width="200" style="background: #fff">
      <a-menu
          mode="inline"
          v-model:selectedKeys="selectedKeys2"
          v-model:openKeys="openKeys"
          :style="{ height: '100%', borderRight: 0 }"
      >
        <a-sub-menu key="sub1">
          <template #title>
                <span>
                  <user-outlined />
                  菜单 1
                </span>
          </template>
          <a-menu-item key="1">option1</a-menu-item>
          <a-menu-item key="2">option2</a-menu-item>
          <a-menu-item key="3">option3</a-menu-item>
          <a-menu-item key="4">option4</a-menu-item>
        </a-sub-menu>
        <a-sub-menu key="sub2">
          <template #title>
                <span>
                  <laptop-outlined />
                  菜单 2
                </span>
          </template>
          <a-menu-item key="5">option5</a-menu-item>
          <a-menu-item key="6">option6</a-menu-item>
          <a-menu-item key="7">option7</a-menu-item>
          <a-menu-item key="8">option8</a-menu-item>
        </a-sub-menu>
        <a-sub-menu key="sub3">
          <template #title>
                <span>
                  <notification-outlined />
                  菜单 3
                </span>
          </template>
          <a-menu-item key="9">option9</a-menu-item>
          <a-menu-item key="10">option10</a-menu-item>
          <a-menu-item key="11">option11</a-menu-item>
          <a-menu-item key="12">option12</a-menu-item>
        </a-sub-menu>
      </a-menu>
    </a-layout-sider>
    <a-layout-content
        :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
    >
      <a-list item-layout="vertical" size="large" :pagination="pagination" :data-source="listData">
        <template #footer>
          <div>
            <b>ant design vue</b>
            footer part
          </div>
        </template>
        <template #renderItem="{ item }">
          <a-list-item key="item.title">
            <template #actions>
          <span v-for="{ type, text } in actions" :key="type">
            <component v-bind:is="type" style="margin-right: 8px" />
            {{ text }}
          </span>
            </template>
            <template #extra>
              <img
                  width="272"
                  alt="logo"
                  src="https://gw.alipayobjects.com/zos/rmsportal/mqaQswcyDLcXyDKnZfES.png"
              />
            </template>
            <a-list-item-meta :description="item.description">
              <template #title>
                <a :href="item.href">{{ item.title }}</a>
              </template>
              <template #avatar><a-avatar :src="item.avatar" /></template>
            </a-list-item-meta>
            {{ item.content }}
          </a-list-item>
        </template>
      </a-list>
    </a-layout-content>
  </a-layout>
</template>
<script lang="ts">
import {defineComponent,reactive,toRef} from "vue";
import axios from "axios";
import { StarOutlined, LikeOutlined, MessageOutlined } from '@ant-design/icons-vue';

const listData: Record<string, string>[] = [];

for (let i = 0; i < 23; i++) {
  listData.push({
    href: 'https://www.antdv.com/',
    title: `ant design vue part ${i}`,
    avatar: 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
    description:
        'Ant Design, a design language for background applications, is refined by Ant UED Team.',
    content:
        'We supply a series of design principles, practical patterns and high quality design resources (Sketch and Axure), to help people create their product prototypes beautifully and efficiently.',
  });
}

export default defineComponent({
  name:'Home',
  components: {
    StarOutlined,
    LikeOutlined,
    MessageOutlined,
  },
  setup(){
    const ebooks = reactive({books:[]});
    axios.get("http://localhost:8081/ebook/search?name=教程").then((response) => {
      const data = response.data;
      ebooks.books = data.content;
    });
    const pagination = {
      onChange: (page: number) => {
        console.log(page);
      },
      pageSize: 3,
    };
    const actions: Record<string, string>[] = [
      { type: 'StarOutlined', text: '156' },
      { type: 'LikeOutlined', text: '156' },
      { type: 'MessageOutlined', text: '2' },
    ];
    return {
      books: toRef(ebooks,"books"),
      listData,
      pagination,
      actions,
    }
  },
})
</script>

然后进行改造:

  • datasource:数据来源,我们改成books

  • 删掉template #footer

  • item是对books的循环,可改名可不改,通过 . 来获取内容

  • 删除右边的大图template

  • 字段修正为数据库对应的字段,例如title改为name

  • 通过:grid="{ gutter: 16, column: 4 }" 属性改为栅格列表,gutter是间距px,column是列数改为4

  • pageSize每页数目改为5

  • 修改图标为方形,添加css样式,scoped表示仅在当前文件生效

改完后:

<template>
  <a-layout>
    <a-layout-sider width="200" style="background: #fff">
      <a-menu
          mode="inline"
          v-model:selectedKeys="selectedKeys2"
          v-model:openKeys="openKeys"
          :style="{ height: '100%', borderRight: 0 }"
      >
        <a-sub-menu key="sub1">
          <template #title>
                <span>
                  <user-outlined />
                  菜单 1
                </span>
          </template>
          <a-menu-item key="1">option1</a-menu-item>
          <a-menu-item key="2">option2</a-menu-item>
          <a-menu-item key="3">option3</a-menu-item>
          <a-menu-item key="4">option4</a-menu-item>
        </a-sub-menu>
        <a-sub-menu key="sub2">
          <template #title>
                <span>
                  <laptop-outlined />
                  菜单 2
                </span>
          </template>
          <a-menu-item key="5">option5</a-menu-item>
          <a-menu-item key="6">option6</a-menu-item>
          <a-menu-item key="7">option7</a-menu-item>
          <a-menu-item key="8">option8</a-menu-item>
        </a-sub-menu>
        <a-sub-menu key="sub3">
          <template #title>
                <span>
                  <notification-outlined />
                  菜单 3
                </span>
          </template>
          <a-menu-item key="9">option9</a-menu-item>
          <a-menu-item key="10">option10</a-menu-item>
          <a-menu-item key="11">option11</a-menu-item>
          <a-menu-item key="12">option12</a-menu-item>
        </a-sub-menu>
      </a-menu>
    </a-layout-sider>
    <a-layout-content
        :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
    >
      <a-list item-layout="vertical" size="large" :pagination="pagination" :data-source="books" :grid="{ gutter: 16, column: 4 }" >
        <template #renderItem="{ item }">
          <a-list-item key="item.name">
            <template #actions>
          <span v-for="{ type, text } in actions" :key="type">
            <component v-bind:is="type" style="margin-right: 8px" />
            {{ text }}
          </span>
            </template>

            <a-list-item-meta :description="item.description">
              <template #title>
                <a :href="item.href">{{ item.name }}</a>
              </template>
              <template #avatar><a-avatar :src="item.avatar" /></template>
            </a-list-item-meta>
            {{ item.content }}
          </a-list-item>
        </template>
      </a-list>
    </a-layout-content>
  </a-layout>
</template>
<script lang="ts">
import {defineComponent,reactive,toRef} from "vue";
import axios from "axios";
import { StarOutlined, LikeOutlined, MessageOutlined } from '@ant-design/icons-vue';

const listData: Record<string, string>[] = [];

export default defineComponent({
  name:'Home',
  components: {
    StarOutlined,
    LikeOutlined,
    MessageOutlined,
  },
  setup(){
    const ebooks = reactive({books:[]});
    axios.get("http://localhost:8081/ebook/search?name=教程").then((response) => {
      const data = response.data;
      ebooks.books = data.content;
    });
    const pagination = {
      onChange: (page: number) => {
        console.log(page);
      },
      pageSize: 5,
    };
    const actions: Record<string, string>[] = [
      { type: 'StarOutlined', text: '156' },
      { type: 'LikeOutlined', text: '156' },
      { type: 'MessageOutlined', text: '2' },
    ];
    return {
      books: toRef(ebooks,"books"),
      listData,
      pagination,
      actions,
    }
  },
})
</script>

<style scoped>
  .ant-avatar {
    width: 50px;
    height: 50px;
    line-height: 50px;
    border-radius: 8%;
    margin: 5px 0;
  }
</style>

后端对查询的service进行判空处理:

public List<EbookResp> search(EbookReq req){
    if (!ObjectUtils.isEmpty(req)) {
        List<Ebook> ebookList = ebookMapper.selectByName("%" + req.getName() + "%");
        //将List<Ebook>转换为List<EbookResp>
        List<EbookResp> respList = CopyUtil.copyList(ebookList, EbookResp.class);
        return respList;
    }
    return null;
}

具体见commit 3-3.

Vue cli多环境配置

开发环境和生产环境是不同的,需要对环境的参数进行抽离,单独管理。

web下新建两个文件,分别叫.env.dev和.env.prod。内容分别如下:

NODE_ENV = development
VUE_APP_SERVER = http://localhost:8081
NODE_ENV = production
VUE_APP_SERVER = 生产环境域名

VUE_APP_SERVER是对后端请求的地址

在package.json里修改启动命令:

"scripts": {
  "serve-dev": "vue-cli-service serve --mode dev",
  "serve-prod": "vue-cli-service serve --mode prod",
  "build-dev": "vue-cli-service build --mode dev",
  "build-prod": "vue-cli-service build --mode prod",
  "lint": "vue-cli-service lint"
},

dev、prod是后缀,会自动读取。如果要指定VUE的启动端口,则在参数后加上 --port 端口号 即可:

"serve-dev": "vue-cli-service serve --mode dev --port 9999",

环境定义好了,那么如何在代码里使用呢?直接引用配置信息会比较麻烦,因为每次都要去写一次,我们可以直接定义整个前端项目的base_url即可。

先看看直接引用的方式:

使用process.env.即可获取环境变量:

axios.get(process.env.VUE_APP_SERVER + "/ebook/search?name=教程").then((response) => {
  const data = response.data;
  ebooks.books = data.content;
});

如何统一配置呢?在main.ts里配置:

import axios from "axios";

axios.defaults.baseURL = process.env.VUE_APP_SERVER;

此时Home.vue里就不用写域地址了:

axios.get("/ebook/search?name=教程").then((response) => {
  const data = response.data;
  ebooks.books = data.content;
});

具体代码见commit 3-4.

使用axios拦截器打日志

手动console.log会比较麻烦,要指定打什么,而且还要写很多次,因此我们使用axios提供的拦截器来打日志,会很方便。

在main.ts里编写拦截器:

/**
 * axios拦截器
 */
axios.interceptors.request.use(function (config) {
    console.log('请求参数:', config);
    return config;
}, error => {
    return Promise.reject(error);
});
axios.interceptors.response.use(function (response) {
    console.log('返回结果:', response);
    return response;
}, error => {
    return Promise.reject(error);
});

此时可以在浏览器控制台看到详细日志。

见commit 3-5

使用SpringBoot过滤器统计接口耗时

后端新建filter包,新建LogFilter类,内容如下:

@Component
 public class LogFilter implements Filter {

     private static final Logger LOG = LoggerFactory.getLogger(LogFilter.class);

     @Override
     public void init(FilterConfig filterConfig) throws ServletException {

     }

     @Override
     public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
         // 打印请求信息
         HttpServletRequest request = (HttpServletRequest) servletRequest;
         LOG.info("------------- LogFilter 开始 -------------");
         LOG.info("请求地址: {} {}", request.getRequestURL().toString(), request.getMethod());
         LOG.info("远程地址: {}", request.getRemoteAddr());
         //打印接口耗时
         long startTime = System.currentTimeMillis();   //开始时间
         filterChain.doFilter(servletRequest, servletResponse);
         LOG.info("------------- LogFilter 结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);
     }
 }

然后访问接口,就能在控制台里看到耗时了。

见commit 3-6.

使用SpringBoot拦截器

新建interceptor层,新建LogInterceptor.java,内容如下:

/**
 * 拦截器:Spring框架特有的,常用于登录校验,权限校验,请求日志打印 /login
 */
@Component
public class LogInterceptor implements HandlerInterceptor {

    private static final Logger LOG = LoggerFactory.getLogger(LogInterceptor.class);

    //先执行的方法
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 打印请求信息
        LOG.info("------------- LogInterceptor 开始 -------------");
        LOG.info("请求地址: {} {}", request.getRequestURL().toString(), request.getMethod());
        LOG.info("远程地址: {}", request.getRemoteAddr());

        long startTime = System.currentTimeMillis();
        request.setAttribute("requestStartTime", startTime);
        return true;    //false时不会执行业务代码
    }

    //后执行的方法
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        long startTime = (Long) request.getAttribute("requestStartTime");
        LOG.info("------------- LogInterceptor 结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);
    }
}

Config层下新建SpringMvcConfig.java,内容如下:

/**
 * 拦截器打印耗时
 * Created by tangssst@qq.com on 2021/06/09
 */
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
    @Resource
    private LogInterceptor logInterceptor;

    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(logInterceptor)
                .addPathPatterns("/**").excludePathPatterns("/login");
    }

}

针对除了/login的所有路径都执行拦截器。

过滤器的范围比拦截器大。

见commit 3-7.

使用AOP

pom.xml:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

新建aspect层,新建LogAspect.java

@Aspect
@Component
public class LogAspect {

    private final static Logger LOG = LoggerFactory.getLogger(LogAspect.class);

    /**
     * 定义一个切点,作用于所有的controller的所有的.Controller的所有方法(所有参数)
     */
    @Pointcut("execution(public * com.example.*.controller..*Controller.*(..))")
    public void controllerPointcut() {
    }

    /**
     * 前置通知before,业务代码之前执行,joinPoint是业务代码的参数
     * @param joinPoint
     * @throws Throwable
     */
    @Before("controllerPointcut()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {

        // 开始打印请求日志
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Signature signature = joinPoint.getSignature();
        String name = signature.getName();

        // 打印请求信息
        LOG.info("------------- 开始 -------------");
        LOG.info("请求地址: {} {}", request.getRequestURL().toString(), request.getMethod());
        LOG.info("类名方法: {}.{}", signature.getDeclaringTypeName(), name);
        LOG.info("远程地址: {}", request.getRemoteAddr());

        // 打印请求参数
        Object[] args = joinPoint.getArgs();
        // LOG.info("请求参数: {}", JSONObject.toJSONString(args));

        Object[] arguments = new Object[args.length];
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof ServletRequest
                    || args[i] instanceof ServletResponse
                    || args[i] instanceof MultipartFile) {
                continue;
            }
            arguments[i] = args[i];
        }
        // 排除一些不该打印出来的敏感字段,或太长的字段不显示。此处排除password和file字段
        String[] excludeProperties = {"password", "file"};
        PropertyPreFilters filters = new PropertyPreFilters();
        PropertyPreFilters.MySimplePropertyPreFilter excludefilter = filters.addFilter();
        excludefilter.addExcludes(excludeProperties);
        LOG.info("请求参数: {}", JSONObject.toJSONString(arguments, excludefilter));
    }

    /**
     * 环绕通知Around,在业务内容前面和后面执行
     * @param proceedingJoinPoint
     * @return
     * @throws Throwable
     */
    @Around("controllerPointcut()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        //前面
        long startTime = System.currentTimeMillis();
        //业务代码
        Object result = proceedingJoinPoint.proceed();
        //后面
        // 排除字段,敏感字段或太长的字段不显示
        String[] excludeProperties = {"password", "file"};
        PropertyPreFilters filters = new PropertyPreFilters();
        PropertyPreFilters.MySimplePropertyPreFilter excludefilter = filters.addFilter();
        excludefilter.addExcludes(excludeProperties);
        LOG.info("返回结果: {}", JSONObject.toJSONString(result, excludefilter));
        LOG.info("------------- 结束 耗时:{} ms -------------", System.currentTimeMillis() - startTime);
        return result;
    }

}

fastjson用于排除敏感、太长字段。

打日志的话过滤器、拦截器、aop用一个就行,这里用aop,把log过滤器、拦截器、SpringMvcConfig注释掉。

见comm 3-8.

发表评论

匿名网友