使用Node、MySQL、JavaScript实现一个简陋的购物Demo

847 阅读9分钟

准备工作

设计

设计Demo功能、表结构、后端接口、前端页面和交互。

  • 功能。实现简单的购买商品的功能。用户购买商品时直接扣除账户中的金币,不涉及真正的支付,且金币只能为整数;购买成功后也不涉及备货发货收货等流程,直接默认用户购买成功就拿到货物了,订单状态已完成;没有用户登录注册功能,直接使用一个下拉列表进行用户切换,模拟多用户操作;在“我的订单”中,能看到当前用户的订单;商品信息包含:商品图片、标题、价格、销售数量;用户信息包含:用户名、手机号、用户地址、金币数量;订单信息包含:购买的商品信息、购买的商品数量、总价格、收货人、收货人手机号、收货地址(订单中的收货人信息直接使用相应的用户信息)。

  • 表结构。用户和订单之间是一对多的关系,一个用户可能有多个订单,一个订单只能对应一个用户;订单和商品之间是多对多的关系,一个订单可能有多个商品,同一商品可能包含在多个订单中,因为订单和商品多对多的关系,所以需要创建一个关联表。在商品信息中有一个销售数量这种统计类型的字段,直接放在商品表中不合理,因为统计类型的数据的变动一般比商品信息的变动大很多,所以新写一个表,用于存放商品的销售数量。

  • 后端接口。其实对于和用户相关的接口,都需要进行用户身份的验证,如果用户身份验证没有通过,则接口直接401,但是本文中的小demo非常简陋,没有用户登录注册流程,所以以下所有接口都没有进行用户验证。

  • 前端页面和交互 注意购物车部分的数据都是写在前端的数据,没有使用真正的接口请求。购物车部分只是为了让这个demo的购物流程稍微正常一点。

创建数据库

安装Node以及 安装MySQL

根据设计好的表结构创建数据库,并且添加一些必要的数据。在命令行中依次输入以下指令 (为了方便,在命令行输入sql语句的时候使用小写字母,为了直观,在代码中使用大写字母)

  • mysql -u root -p,以root的身份使用数据库。

  • create database shopping,创建一个名为shopping的数据库。

  • use shopping,使用创建好的shopping数据库。

  • 创建商品表

    create table commodities 
    (
      commodity_id int unsigned not null auto_increment primary key,
      title char(100),
      price int unsigned,
      image_url char(100),
      created_time datetime
    );
    
  • 创建用户表:

    create table users
    (
      user_id int unsigned not null auto_increment primary key,
      name char(50) not null,
      telephone_number char(20) not null,
      address char(50) not null,
      coins_quantity int unsigned,
      created_time datetime
    );
    
  • 创建订单表:

    create table orders
    (
      order_id int unsigned not null auto_increment primary key,
      user_id int unsigned not null,
      amount int unsigned,
      created_time datetime
    );
    
  • 创建订单和商品的关联表:

    create table orders_commodities
    (
      order_id int unsigned not null,
      commodity_id int unsigned not null,
      commodities_quantity int unsigned,
      primary key (order_id, commodity_id)
    );
    

    订单和商品的关联表有两个主键,订单id和商品id。

  • 创建商品统计数据的表:

    create table commodities_statistics
    (
      commodity_id int unsigned not null primary key,
      sales_volume int unsigned
    );
    

    因为省略了用户注册、商品创建等功能,所以一开始就手动添加好用户表users和商品表commodities,以及commodities_statistics表中的数据。这里只简单说明下使用命令行工具如何往表中插入数据,剩下的用户数据和商品数据使用Navicat Premium 添加。向users表中插入一条数据:

    insert into users values
    (NULL, '小起', '13012345678', '某国某省某市', 100, '2020-01-30 00:00:00');
    
  • 创建一个管理员用户,用于后续Node服务器连接MySQL数据库。

    create user shopping_manager identified by 'secret';
    

    给用户添加对数据库进行增删改查的权限:

    grant insert, delete, update, select
    on shopping.*
    to shopping_manager;
    

    允许用户改变现存表的结构:

    alter user 'shopping_manager' 
    identified with mysql_native_password by 'secret';
    

    在不重启服务的情况下使对用户权限的修改生效:

    flush privileges;
    

后端实现

搭建一个简单的服务器

初始化项目: npm init -y

创建需要的文件和文件夹:

touch server.js serverRequestHandlers.js && mkdir resources

server.js是服务器的入口文件,serverRequestHandlers.js是对具体请求的接口的处理方法。resources文件夹中存放的是商品图片。 server.js中有几个主要的方法:

对请求资源的处理

  function requestResource (req, res, parsedUrl) {
    let pathname = parsedUrl.pathname;
    let filePath = pathname.substr(1);
    filePath = filePath === '' ? 'index.html' : filePath;
    let stream = fs.createReadStream(filePath);
    let responseData = [];
    if (stream) {
      stream.on('data', (chunk) => {
        responseData.push(chunk);
      });
      stream.on('end', () => {
        let total = Buffer.concat(responseData);
        res.writeHead(200);
        res.write(total);
        res.end();
      });
    }
  }

根据请求url,读取相应位置的文件二进制数据并返回给前端。

对GET请求的处理

function handleGetRequest (req, res, parsedUrl) {
  const { query, pathname } = parsedUrl;
  let parsedData = {};
  if (query) {
    let queryString = decodeURIComponent(query);
    let items = queryString.split('&');
    for (let item of items) {
      let arr = item.split('=');
      let key = arr[0];
      let value = JSON.parse(arr[1]);
      parsedData[key] = value;
    }
  }
  requestHandlers({ api: pathname, req, res, params: parsedData, createConnection });
}

将请求的url中带的数据整理为一个对象,作为参数传递到requestHandlers中,requestHandlers会对不同的请求url进行不同的处理,处理完成后通过res.end(JSON.stringify(results));这样的代码将结果返回给前端。

对POST请求的处理

function handlePostRequest (req, res, parsedUrl) {
  const { pathname } = parsedUrl;
  req.on('error', (err) => {
    res.end(`请求失败:${err}`);
  });
  let body = [];
  req.on('data', (chunk) => {
    body.push(chunk);
  });
  req.on('end', () => {
    const bodyBuffer = Buffer.concat(body);
    const bodyString = bodyBuffer.toString();
    let parsedData = JSON.parse(bodyString);
    requestHandlers({ api: pathname, req, res, params: parsedData, createConnection });
  });
}

POST请求中携带的请求数据是二进制数据,将请求数据也就是body拿到后,将body转换为字符串再通过JSON解析,整理出想要的请求参数。和GET请求一样,将整理出的对象作为参数传递到requestHandlers中,requestHandlers会对不同的请求url进行不同的处理,处理完成后通过res.end(JSON.stringify(results));这样的代码将结果返回给前端。

连接数据库

安装sql包

npm i sql --save

在server.js文件中添加以下代码:

const mysql = require('mysql');
const createConnection = () => {
  return mysql.createConnection({
    host: 'localhost',
    user: 'shopping_manager',
    password : 'secret',
    database : 'shopping',
    multipleStatements: true, // 允许一次使用多个查询语句,这在定义sql变量@inserted_order_id的时候需要用到
  });
};

createConnection是用于创建一个连接实例的,每次连接时都创建一个新的实例,数据库操作完成后使用connection实例的end方法断开连接。

各个接口的实现

对各个接口请求的具体处理放在serverRequestHandlers.js中。

获取用户列表

function getUsersList (paramsObj) {
  const { createConnection, res } = paramsObj;
  const connection = createConnection();
  connection.connect();
  connection.query('SELECT * FROM users', function (error, results, fields) {
    if (error) throw error;
    res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
    res.end(JSON.stringify(results));
  });
  connection.end();
}

将数据库中users的所有数据返回给前端。

获取商品列表

获取商品列表和获取用户列表的方式一样,只是sql语句不同。

const sqlCommand = `
  SELECT *
  FROM commodities
  LEFT JOIN commodities_statistics
  USING (commodity_id)
`;
connection.query(sqlCommand, function (error, results
...

commodities表和commodities_statistics表中都有commodity_id列,根据commodity_id列的值将commodities_statistics中commodities表没有的列合并到结果中。因为返回的信息需要包含商品的基础信息以及商品的销售数量。

获取订单列表

获取订单列表和获取用户列表的方式一样,只是sql语句不同,并且sql不能直接拿到前端需要的数据,还需要经过整理。

const sqlCommand = `
  SELECT o.order_id, o.amount, o.user_id, o.created_time,
        oc.commodity_id, oc.commodities_quantity,
        c.title, c.price, c.image_url
  FROM orders AS o
  INNER JOIN orders_commodities AS oc
  ON o.order_id = oc.order_id
  INNER JOIN commodities AS c
  ON oc.commodity_id = c.commodity_id
  WHERE o.user_id = ${user_id}
`;

将commodities表和orders_commodities合并在一起,就得到了一张包含商品信息的表1,将orders表和orders_commodities合并在一起,就得到了包含订单信息的表2,将三张表结合在一起,就得到了同时包含商品信息,订单信息的多对多的表3。user_id是从请求中解析出的user_id的值,从表3再筛选出订单的user_id等于解析出的user_id的数据。这个sql语句最终得到的结果是一个可能有多个相同的order_id和多个相同的commodity_id、user_id唯一、包含订单信息和商品信息的表4。关于join语句可以参考mysql join。 将表4的数据整理为前端所需的数据并返给前端:

// 根据order_id将数据整理成订单列表
let ordersList = [];
let ordersInfo = {};
results.forEach((item) => {
  const { order_id, amount, commodity_id, commodities_quantity, title, price, image_url, created_time } = item;
  if (!ordersInfo[order_id]) {
    ordersInfo[order_id] = {
      order_id, // 订单id
      amount, // 订单总价
      commodities: [], // 商品列表
      created_time, // 创建时间
    };
  }
  const commodity = {
    commodity_id,
    commodities_quantity,
    title,
    price,
    image_url
  };
  ordersInfo[order_id].commodities.push(commodity);
});
for (let key in ordersInfo) {
  ordersList.push(ordersInfo[key]);
}
res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
res.end(JSON.stringify(ordersList));

创建订单

function createOrder (paramsObj) {
  const { params, createConnection, res } = paramsObj;
  const connection = createConnection();
  let { user_id, amount, commodities } = params;
  let changeCommoditiesQuantity = ''; // 修改已售商品的数量
  let addOrderCommodity = ''; // 添加订单和商品之间的关系
  const commoditiesIds = commodities.map((item) => {
    const { commodity_id, commodities_quantity } = item;
    changeCommoditiesQuantity += ` WHEN ${commodity_id} THEN sales_volume + ${commodities_quantity} `;
    addOrderCommodity += ` INSERT INTO orders_commodities VALUES (@inserted_order_id, ${commodity_id}, ${commodities_quantity}); `;
    return item.commodity_id;
  });
  const commoditiesIdsString = commoditiesIds.join(', ');
  let createdTime = new Date();
  createdTime = formatDateTime(createdTime);
  let sqlCommand = `
    INSERT INTO orders (user_id, amount, created_time) VALUES (${user_id}, ${amount}, '${createdTime}');
    SET @inserted_order_id = 0;
    ${addOrderCommodity}
    UPDATE commodities_statistics 
    SET sales_volume = CASE commodity_id
    ${changeCommoditiesQuantity}
    END
    WHERE commodity_id IN (${commoditiesIdsString});
    UPDATE users
    SET coins_quantity = coins_quantity - ${amount}
    WHERE user_id = ${user_id};
  `;
  connection.connect();
  connection.query(sqlCommand, function (error, results, fields) {
    if (error) throw error;
    res.writeHead(200, {'Content-Type': 'text/palin; charset=utf-8'});
    res.end(JSON.stringify({ code: 0 })); // 直接使用code为0表示创建成功
  });
  connection.end();
}
  • 首先用请求中拿到的数据往orders表中插入一条数据。为了通过0拿到新插入行的order_id,并将它的值存储在一个变量中以供后面使用,需要使用语句SET @inserted_order_id = 0;,所以在用createConnection创建connection实例的时候,需要添加上属性:multipleStatements: true,允许一次使用多个查询语句。
  • 修改commodities_statistics表的数据 一个订单中可能包含多个商品,每个商品有自己的数量,这个时候需要对不同的商品的销售数量进行不同的修改。比如订单中包含一件commodity_id为1的商品A,两件commodity_id为2的商品B,那么商品A的数量需要加1,商品B的数量需要加2,需要执行的指令为:
    UPDATE commodities_statistics 
    SET sales_volume = CASE commodity_id
    WHEN 1 THEN sales_volume + 1
    WHEN 2 THEN sales_volume + 2
    END
    WHERE commodity_id IN (1, 2);
    
    按照这个规律整理出修改商品销售数量的指令。
  • 修改users表的数据 要根据订单总共的价格,扣除用户的金币,即users表中对应用户的金币数量减小相应的值。

前端实现

静态页面

创建文件touch index.css index.html index.js之后,根据上文的设计图,创建出静态的页面。

定义了全局变量appData来存储全局数据,其中commoditiesList存放的是所有商品的信息,存放商品列表信息是因为购物车页面是假页面,没有使用接口请求但又需要用到商品信息。

以切换标签为例说明一下静态页面的创建。这个小demo主要分为3个页面,商店、购物车和我的订单。以绝对定位的方式创建了3个div,这3个div的z-index都为-2,要展示某个页面,就将对应的z-index设置为-1即可。

function changTab (tabElement, tabName) {
  const nextTag = tabElement || document.getElementById(tabName);
  const previousPart = appData.currentPart;
  if (previousPart && previousPart === tabName) return;
  const previousPartTabElement = document.getElementById(previousPart);
  previousPartTabElement.className = previousPartTabElement.className.slice(0, -9);
  appData.currentPart = tabName;
  nextTag.className += ' selected';
  changeContent(previousPart, tabName);
}
// 根据不同的标签切换内容
function changeContent (previousPart, nextPart) {
  const previousContentElement = document.getElementById(previousPart + 'Content');
  const nextContentElement = document.getElementById(nextPart + 'Content');
  previousContentElement.className = previousContentElement.className.slice(0, -8);
  nextContentElement.className += ' current';
  switch (nextPart) {
    case 'shop':
      displayShop();
      break;
    case 'cart':
      displayCart();
      break;
    case 'orders':
      displayOrders();
      break;
  }
}

appData中的currentPart用于存放表示不同页面的字段:shop、cart、orders。这个字段的值和html中相应元素的dataset对应。

<div class="tabs">
  <div data-tab="shop" id="shop" class="tab">商店</div>
  <div data-tab="cart" id="cart" class="tab">购物车</div>
  <div data-tab="orders" id="orders" class="tab">我的订单</div>
</div>

初始情况下默认设置当前页面是商店页面,后续切换的时候,将前一个页面对应的标签元素和内容元素的选中类名去掉,给即将跳转的页面对应的元素加上选中类名。

请求接口数据组织页面

根据已经创建好的静态页面,拿请求接口得到的数据创建元素,组织页面。 比如获取用户信息并创建下拉组件部分代码:

async function getUsesInfo () {
  const { requstApis } = appData;
  const users = await request({ url: requstApis.getUsersInfo });
  appData.usersInfo = users;
  appData.currentUser = users[0];
  const selectUserElement = document.getElementById('selectUser');
  const fragment = document.createDocumentFragment();
  users.forEach((user) => {
    const { user_id, name } = user;
    const option = document.createElement('option');
    option.value = user_id;
    option.textContent = name;
    fragment.appendChild(option)
  });
  selectUserElement.append(fragment);
  appData.currentUser = users[0];
  selectUserElement.value = users[0].user_id;
  selectUserElement.onchange = (event) => {
    appData.currentUser = appData.usersInfo.find((item) => item.user_id === parseInt(event.target.value));
  };
}

使用createDocumentFragment创建的fragment,防止频繁向页面中append元素,减少重排和重绘。 request是封装的一个简陋的请求方法:

function request ({url, method, params}) {
  if (!method || method === 'GET' ) { // GET请求
    let queryString = '';
    if (params) {
      for (let key in params) {
        queryString += `${key}=${params[key]}&`
      }
    }
    queryString = queryString.slice(0, -1);
    const requestUrl = `${url}?${queryString}`;
    return fetch(requestUrl)
      .then(function(response) {
        return response.json();
      })
  }
  if (method === 'POST') {
    return fetch(url, {
      method: 'POST',
      body: JSON.stringify(params),
      headers: {
        'content-type': 'application/json'
      },
    })
    .then(function(response) {
      return response.json();
    })
  }
}

其他

源码地址。最终实现效果: