github
articles
前言
在上一篇文章中,主要是对项目做了介绍,并且对系统分析和系统设计做了大概的介绍。那么接下来这篇文章会对系统的实现做介绍,主要是选择一些比较主要的模块或者说可拿出来与大家分享的模块。好了,接入正题吧~~
MongoDB
服务端这边使用的是Express框架,数据库使用的是MongoDB,通过Mongoose模块来操作数据库。这边主要是想下对 MongoDB 做个介绍,当然看官了解的话直接往下划~~~~~~
在项目开始前要确保电脑是否安装mongoDB,下载点我, 图像化工具Robo 3T 点我 ,下载好具体怎么配置还请问度娘或Google吧,本文不做介绍了哈。注意:安装完mongoDB的时候进行项目时要把lib目录下的mongod服务器打开哈~~
MongoDB 是一个基于分布式文件存储的数据库,是一个介于关系型数据库和非关系型数据库之间的开源产品,它是功能最为丰富的非关系型数据库,也是最像关系型数据库的。但是和关系型数据库不同,MongoDB没有表和行的概念,而是一个面向 集合、文档
的数据库。其中的文档是一个键值对,采用BSON(Binary Serialized Document Format),BSON是一种类似于JSON的二进制形式的存储格式,并且BSON具有表示数据类型的扩展,因此支持的数据非常丰富。MongoDB有两个很重要的数据类型就是 内嵌文档和数组
,而且在数组内可以嵌入其他文档,这样一条记录就能表示非常复杂的关系。
Mongoose是在node.js异步环境下对MongoDB进行简便操作的对象模型工具,能从数据库提取任何信息,可以用面向对象的方法来读写数据,从而使操作MongoDB数据库非常便捷。Mongoose中有三个非常重要的概念,便是Schema(模式),Model(模型),Entity(实体)。
- Schema: 一种以文件形式存储的数据库模型骨架,不具备数据库的操作能力,创建它的过程如同关系型数据库建表的过程,如下:
//Schema const mongoose = require('mongoose'); const Schema = mongoose.Schema; const UserSchema = new Schema({ token: String, is_banned: {type: Boolean, default: false}, //是否禁言 enable: { type: Boolean, default: true }, //用户是否有效 is_actived: {type: Boolean, default: false}, //邮件激活 username: String, password: String, email: String, //email唯一性 code: String, email_time: {type: Date}, phone: {type: String}, description: { type: String, default: "这个人很懒,什么都没有留下..." }, avatar: { type: String, default: "http://p89inamdb.bkt.clouddn.com/default_avatar.png" }, bg_url: { type: String, default: "http://p89inamdb.bkt.clouddn.com/FkagpurBWZjB98lDrpSrCL8zeaTU"}, ip: String, ip_location: { type: Object }, agent: { type: String }, // 用户ua last_login_time: { type: Date }, ..... }); 复制代码
- Model: 由Schema发布生成的模型,具有抽象属性和行为的数据库操作对象
//生成一个具体User的model并导出 const User = mongoose.model("User", UserSchema); //第一个参数是集合名,在数据库中会把Model名字字母全部变小写和在后面加复数s //执行到这个时候你的数据库中就有了 users 这个集合 module.exports = User; 复制代码
- Entity: 由Model创建的实体,他的操作也会影响数据库,但是它操作数据库的能力比Model弱
const newUser = new UserModel({ //UserModel 为导出来的 User email: req.body.email, code: getCode(), email_time: Date.now() }); 复制代码
Mongoose中有一个东西个人感觉非常主要,那便是 populate
,通过populate他可以很方便的与另一个集合建立关系。如下,user集合可以与article集合、user集合本身进行关联,根据其内嵌文档的特性,这样子他便可以内嵌子文档,子文档中有可以内嵌子文档,这样子它返回的数据就会异常的丰富。
const user = await UserModel.findOne({_id: req.query._id, is_actived: true}, {password: 0}).populate({ path: 'image_article', model: 'ImageArticle', populate: { path: 'author', model: 'User' } }).populate({ path: 'collection_film_article', model: 'FilmArticle', }).populate({ path: 'following_user', model: 'User', }).populate({ path: 'follower_user', model: 'User', }).exec(); 复制代码
服务端主要是操作数据库,对数据库进行增删改查(CRUD)等操作。项目中的接口,Mongoose的各种方法这边就不对其做详细介绍,大家可以查看Mongoose文档。
用户身份认证实现
介绍
本系统的用户身份认证机制采用的是 JSON Web Token(JWT)
,它是一种轻量的认证规范,也用于接口的认证。我们知道,HTTP协议是一种无状态的协议,这便意味着每个请求都是独立的,当用户提供了用户名和密码来对我们的应用进行用户认证,那么在下一次请求的时候,用户需要再进行一次用户的认证才可以,因为根据HTTP协议,我们并不能知道是哪个用户发出的请求,本系统采用了token的鉴权机制。这个token必须要在每次请求时传递给服务端,它应该保存在请求头里,另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *。
在用户身份认证这一块有很多方法,最常见的像cookie ,session。那么他们三之间又有什么区别,这里有两篇文章介绍的挺全面。
token 与 session的区别在于,它不同于传统的session认证机制,它不需要在服务端去保留用户的认证信息或其会话的信息。系统一旦比较大,都会采用机器集群来做负载均衡,这需要多台机器,由于session是保存在服务端,那么就要 去考虑用户到底是在哪一台服务器上进行登录的,这便是一个很大的负担。
那么就有人想问了,你这个系统这么小,为什么不使用传统的session机制呢?哈~因为之前自己的项目一般都是使用session做登录,没使用过token,想尝试尝试入入坑~~哈哈哈~
实现思路
JWT主要的实现思路如下:
-
在用户登录成功的时候创建token保存于数据库中,并返回给客户端。
-
客户端之后的每一次请求都要带上token,在请求头里加入Authorization,并加上token.
-
在服务端进行验证token的有效性,在有效期内返回200状态码,token过期则返回401状态码
如下图所示:
在node中主要用了 jsonwebtoken
这个模块来创建JWT,jsonwebtoken的使用请查看jsonwebtoken文档。项目中创建token的中间件createToken如下
/** * createToken.js */ const jwt = require('jsonwebtoken'); // 引入jsonwebtoken模块 const secret = '我是密钥' //登录时:核对用户名和密码成功后,应用将用户的id(user_id)作为JWT Payload的一个属性 module.exports = function(user_id){ const token = jwt.sign({ user_id: user_id }, secret, { //密钥 expiresIn: '24h' //过期时间设置为24h。那么decode这个token的时候得到的过期时间为:创建token的时间+设置的值 }); return token; }; 复制代码
return 出来的 token 类似 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYWRtaW4iLCJpYXQiOjE1MzQ2ODQwNzAsImV4cCI6MTUzNDc3MDQ3MH0.Y3kaglqW9Fpe1YxF_uF7zwTV224W4W97MArU0aI0JgM
。我们仔细看这字符串,分为三段,分别被 "." 隔开。现在我们分别对前两段进行base64解码如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ===> {"alg":"HS256","typ":"JWT"} 其中 alg是加密算法名字,typ是类型 eyJ1c2VyX2lkIjoiYWRtaW4iLCJpYXQiOjE1MzQ2ODQwNzAsImV4cCI6MTUzNDc3MDQ3MH0 ===> {"user_id":"admin","iat":1534684070,"exp":1534770470} 其中 name是我们储存的内容,iat创建的时间戳,exp到期时间戳。 Y3kaglqW9Fpe1YxF_uF7zwTV224W4W97MArU0aI0JgM ===> 最后一段是由前面两段字符串,HS256加密后得到。所以前面的任何一个字段修改,都会导致加密后的字符串不匹配。 复制代码
当我们根据用户的id创建获取到token之后,我们需要把token返回到客户端,客户端对其在本地(localStorage)保存, 客户端之后的每一次请求都要带上token,在请求头里加入Authorization,并加上token,服务端进行验证token的有效性。那么我们如何验证token的有效性呢? 所以我们需要checkToken这个中间件来检测token的有效性。
/** * checkToken */ const jwt = require('jsonwebtoken'); const secret = '我是密钥' module.exports = async ( req, res, next ) => { const authorization = req.get('Authorization'); if (!authorization) { res.status(401).end(); //接口需要认证但是有没带上token,返回401未授权状态码 return } const token = authorization.split(' ')[1]; try { let tokenContent = await jwt.verify(token, secret); //如果token过期或验证失败,将抛出错误 next(); //执行下一个中间件 } catch (err) { console.log(err) res.status(401).end(); //token过期或者验证失败返回401状态码 } } 复制代码
那么现在咱们只要在需要用户认证的接口上,在操作数据之前,加上checkToken中间件即可,如下调用:
//更新用户信息 router.post('/updateUserInfo', checkToken, User.updateUserInfo) //如果checkToken检测不成功,它便返回401状态码,不会对User.updateUserInfo做任何操作, 只有检测token成功,才能处理User.updateUserInfo 复制代码
我们如何保证每次请求都能在请求头里加入Authorization,并加上token,这就要用到Axios的请求拦截,并且也用到了它的响应拦截,因为在服务端返回401状态码之后应要执行登出操作,清楚本地token的存储,具体代码如下:
//request拦截器 instance.interceptors.request.use( config => { //每次发送请求之前检测本地是否存有token,都要放在请求头发送给服务器 if(localStorage.getItem('token')){ if (config.url.indexOf('upload-z0.qiniup.com/putb64') > -1){ config.headers.Authorization = config.headers['UpToken']; //加上七牛云上传token } else { config.headers.Authorization = `token ${localStorage.getItem('token')}`.replace(/(^\")|(\"$)/g, ''); //加上系统接口token } } console.log('config',config) return config; }, err => { console.log('err',err) return Promise.reject(err); } ); //response拦截器 instance.interceptors.response.use( response => { return response; }, error => { //默认除了2XX之外的都是错误的,就会走这里 if(error.response){ switch(error.response.status){ case 401: console.log(error.response) store.dispatch('ADMIN_LOGINOUT'); //可能是token过期,清除它 router.replace({ //跳转到登录页面 path: '/login', query: { redirect: '/dashboard' } // 将跳转的路由path作为参数,登录成功后跳转到该路由 }); } } return Promise.reject(error.response); } ); 复制代码
其中的if else 是因为本系统的图片,音视频是放在七牛云,上传需要七牛云上传base64图片的时候token是放在请求头的,正常的图片上传不是放在请求头,所以这边对token做了区分,如何接入七牛云也会在下面模块介绍到。
七牛云接入
本系统的图片,音视频是放在七牛云,所以需要接入七牛云。七牛云分了两种情况,正常图片和音视频的上传和base64图片的上传,因为七牛云在对他们两者上传的 Content-Type
和 domain(域)
有所不同,正常图片和音视频的Content-Type是 headers: {'Content-Type':'multipart/form-data'}
domain是 domain='https://upload-z0.qiniup.com'
,而base64图片的上传则是 headers:{'Content-Type':'application/octet-stream'}
domain是 domain='https://upload-z0.qiniup.com/putb64/-1'
,所以他们请求的时候token放的地方不同,base64就像上面所说的放在请求头 Authorization
中,而正常的放在 form-data
中。在服务端通过接口请求来获取七牛云上传token,客户端获取到七牛云token,通过不同方案将token带上。
- base64的上传:
headers:{'Content-Type':'application/octet-stream'}
和domain='https://upload-z0.qiniup.com/putb64/-1'
,token放在请求头Authorization
中。 - 正常图片和音视频的上传:
headers: {'Content-Type':'multipart/form-data'}
和domain='https://upload-z0.qiniup.com'
,token 放在form-data
中。
服务端通过 qiniu
这个模块进行创建token,服务端代码如下:
/** * 构建一个七牛云上传凭证类 * @class QN */ const qiniu = require('qiniu') //导入qiniu模块 const config = require('../config') class QN { /** * Creates an instance of qn. * @param {string} accessKey -七牛云AK * @param {string} secretKey -七牛云SK * @param {string} bucket -七牛云空间名称 * @param {string} origin -七牛云默认外链域名,(可选参数) */ constructor (accessKey, secretKey, bucket, origin) { this.ak = accessKey this.sk = secretKey this.bucket = bucket this.origin = origin } /** * 获取七牛云文件上传凭证 * @param {number} time - 七牛云凭证过期时间,以秒为单位,如果为空,默认为7200,有效时间为2小时 */ upToken (time) { const mac = new qiniu.auth.digest.Mac(this.ak, this.sk) const options = { scope: this.bucket, expires: time || 7200 } const putPolicy = new qiniu.rs.PutPolicy(options) const uploadToken = putPolicy.uploadToken(mac) return uploadToken } } exports.QN = QN; exports.upToken = () => { return new QN(config.qiniu.accessKey, config.qiniu.secretKey, config.qiniu.bucket, config.qiniu.origin).upToken() //每次调用都创建一个token } 复制代码
//获取七牛云token接口 const {upToken} = require('../utils/qiniu') app.get('/api/uploadToken', (req, res, next) => { const token = upToken() res.send({ status: 1, message: '上传凭证获取成功', upToken: token, }) }) 复制代码
由于正常图片和音视频的上传和base64图片的上传,因为七牛云在对他们两者上传的 Content-Type
和 domain(域)
有所不同,所以的token请求存放的位置有所不同,因此要区分,客户端调用上传代码如下:
//根据获取到的上传凭证uploadToken上传文件到指定域 //正常图片和音视频的上传 uploadFile(formdata, domain='https://upload-z0.qiniup.com',config={headers:{'Content-Type':'multipart/form-data'}}){ console.log(domain) console.log(formdata) return instance.post(domain, formdata, config) }, //base64图片的上传 //根据获取到的上传凭证uploadToken上传base64到指定域 uploadBase64File(base64, token, domain = 'https://upload-z0.qiniup.com/putb64/-1', config = { headers: { 'Content-Type': 'application/octet-stream', }, }){ const pic = base64.split(',')[1]; config.headers['UpToken'] = `UpToken ${token}` return instance.post(domain, pic, config) }, 复制代码
function upload(Vue, data, callbackSuccess, callbackFail) { //获取上传token之后处理 Vue.prototype.axios.getUploadToken().then(res => { if (typeof data === 'string'){ //如果是base64 const token = res.data.upToken Vue.prototype.axios.uploadBase64File(data, token).then(res => { if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` }) } }).catch((error) => { callbackFail && callbackFail({ error }) }) } else if (data instanceof FormData){ //如果是FormData data.append('token', res.data.upToken) data.append('key', `moment${Date.now()}${Math.floor(Math.random() * 100)}`) Vue.prototype.axios.uploadFile(data).then(res => { if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` }) } }).catch((error) => { callbackFail && callbackFail({ error }) }) } else { const formdata = new FormData() //如果不是formData 就创建formData formdata.append('token', res.data.upToken) formdata.append('file', data.file || data) formdata.append('key', `moment${Date.now()}${Math.floor(Math.random() * 100)}.${data.file.type.split('/')[1]}`) // 获取到凭证之后再将文件上传到七牛云空间 console.log('formdata',formdata) Vue.prototype.axios.uploadFile(formdata).then(res => { console.log('res',res) if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` //返回的图片链接 }) } }).catch((error) => { console.log(error) callbackFail && callbackFail({ error }) }) } }) } export default upload 复制代码
路由权限模块
系统的后台管理面向的是合作作者和管理员,涉及到两种角色,故此要做权限管理。不同的权限对应着不同的路由,同时侧边栏的菜单也需根据不同的权限,异步生成,不同于以往的服务端直接返回路由表,由前端动态生成,接下来介绍下登录和权限验证的思路:
-
登录:当用户填写完账号和密码后向服务端验证是否正确,验证通过之后,服务端会返回一个token,拿到token之后前端会根据token再去拉取一个getAdminInfo的接口来获取用户的详细信息(如用户权限,用户名等等信息)。
-
权限验证:通过token获取用户对应的role,动态根据用户的role算出其对应有权限的路由,通过vue-router的beforeEach进行全局前置守卫再通过router.addRoutes动态挂载这些路由。
代码有点多,这边就直接放流程图哈~~
最近正好也在公司做中后台项目,公司的中后台项目的这边是由服务端生成路由表,前端进行直接渲染,毕竟公司的一整套业务比较成熟。但是我们会在想能不能由前端维护路由表,这样不用到时候项目迭代,前端每增加页面都要让服务端兄弟配一下路由和权限,当然前提可能是项目比较小的时候。
账号模块
账号模块是业务中最为基础的模块,承担着整个系统所有的账号相关的功能。系统实现了用户注册、用户登录、密码修改、找回密码功能。
系统的账号模块使用了邮件服务,针对普通用户的注册采用了邮件服务来发送验证码,以及密码的修改等操作都采用了邮件服务。在node.js中主要采用了Nodemailer,Nodemailer是一个简单易用的Node.js邮件发送组件,它的使用可以摸我摸我摸我,通过此模块进行邮件的发送。你们可能会问,为什么不用短信服务呢?哈~因为短信服务要钱,哈哈哈
/* * email 邮件模块 */ const nodemailer = require('nodemailer'); const smtpTransport = require('nodemailer-smtp-transport'); const config = require('../config') const transporter = nodemailer.createTransport(smtpTransport({ host: 'smtp.qq.com', secure: true, port: 465, // SMTP 端口 auth: { user: config.email.account, pass: config.email.password //这里密码不是qq密码,是你设置的smtp授权码 } })); let clientIsValid = false; const verifyClient = () => { transporter.verify((error, success) => { if (error) { clientIsValid = false; console.warn('邮件客户端初始化连接失败,将在一小时后重试'); setTimeout(verifyClient, 1000 * 60 * 60); } else { clientIsValid = true; console.log('邮件客户端初始化连接成功,随时可发送邮件'); } }); }; verifyClient(); const sendMail = mailOptions => { if (!clientIsValid) { console.warn('由于未初始化成功,邮件客户端发送被拒绝'); return false; } mailOptions.from = '"ShineTomorrow" <admin@momentin.cn>' transporter.sendMail(mailOptions, (error, info) => { if (error) return console.warn('邮件发送失败', error); console.log('邮件发送成功', info.messageId, info.response); }); }; exports.sendMail = sendMail; 复制代码
账号的注册先是填写email,填写好邮箱之后会通过Nodemailer发送一封含有有效期的验证码邮件,之后填写验证码、昵称和密码即可完成注册,并且为了安全考虑,对密码采用了安全哈希算法(Secure Hash Algorithm)进行加密。账号的登录以账号或者邮箱号加上密码进行登录,并且采用上文所说的JSON Web Token(JWT)身份认证机制,从而实现用户和用户登录状态数据的对应。
实时消息推送
当用户被人关注、评论被他人回复和点赞等一些社交性的操作的时候,在数据存储完成后,服务端应需要及时向用户推送消息来提醒用户。消息推送模块采用了 Socket.io
来实现,socket.io封装了websocket,不支持websocket的情况还提供了降级AJAX轮询,功能完备,设计优雅,是开发实时双向通讯的不二手段。
通过socket.io,用户每打开一个页面,这个页面都会和服务端建立一个连接。在服务端可以通过连接的socket的id属性来匹配到一个建立连接的页面。所以用户的ID和socket的id,是一对多的关系,即一个用户可能在登录后打开多个页面。而socket.io没有提供从服务端向某个用户单独发送消息的功能,更没有提供向某个用户打开的所有页面推送消息的功能。但是socket.io提供了room的概念,即群组。在建立websocket时,客户端可以选择加入某个room,如果这个room没有存在则自动新建一个,否则直接加入,服务端可以向某个room中的所有客户端推送消息。
根据这个特性,设计将用户的ID作为room的名字,当某个用户打开页面建立连接时,会选择加入以自己用户ID为名字的room。这样,在用户ID为名字的 room中,加入的都是用户自己打开的页面建立的连接。从而向某个用户推送消息,可以直接通过向以此用户的ID为名字的room发送消息,这样就会推送到用户打开的所有页面。
有了想法后我们就开始鲁吧~,在服务端中 socket.io
在客户端中使用 vue-socket.io
, 服务端代码如下:
/* * app.js中 */ const server = require('http').createServer(app); const io = require('socket.io')(server); global.io = io; //全局设上io值, 因为在其他模块要用到 io.on('connection', function (socket) { // setTimeout(()=>{ // socket.emit('nodeEvent', { hello: 'world' }); // }, 5000) socket.on('login_success', (data) => { //接受客户端触发的login_success事件 //使用user_id作为房间号 socket.join(data.user_id); console.log('login_success',data); }); }); io.on('disconnect', function (socket) { socket.emit('user disconnected'); }); server.listen(config.port, () => { console.log(`The server is running at http://localhost:${config.port}`); }); 复制代码
/* * 某业务模块 */ //例如某文章增加评论 io.in(newMusicArticle.author.user_id._id).emit('receive_message', newMessage); //实时通知客户端receive_message事件 sendMail({ //发送邮件 to: newMusicArticle.author.user_id.email, subject: `Moment | 你有未读消息哦~`, text: `啦啦啦,我是卖报的小行家~~ `, html: emailTemplate.comment(sender, newMusicArticle, content, !!req.body.reply_to_id) }) 复制代码
客服端代码:
<script> export default { name: 'App', data () { return { } }, sockets:{ connect(){ }, receive_message(val){ //接受服务端触发的事件,进行客户端实时更新数据 if (val){ console.log('服务端实时通信', val) this.$notify(val.content) console.log('this method was fired by the socket server. eg: io.emit("customEmit", data)') } } }, mixins: [mixin], mounted(){ if (!!JSON.parse(window.localStorage.getItem('user_info'))){ this.$socket.emit('login_success', { //通知服务端login_success 事件, 传入id user_id: JSON.parse(window.localStorage.getItem('user_info'))._id }) } }, } </script> 复制代码
评论模块
评论模块是为了移动端WebApp下的文章下为用户提供关于评论的一些操作。系统实现了对文章的评论,评论的点赞功能,热门评论置顶以及评论的回复功能。在评论方面存在着各种各样的安全性问题,比如XSS攻击(Cross Site Scripting,跨站脚本攻击)以及敏感词等问题。预防XSS攻击使用了 xss
模块, 敏感词过滤使用 text-censor
模块。
一些思考
- 接口数据问题
在开发的时候经常会遇到这个问题,接口数据问题。有时候服务端返回的数据并不是我们想要的数据,前端要对数据进行再一步的处理。
例如服务端返回的某个字段为null或者服务端返回的数据结构太深,前端需要不断去判断数据结构是否真的返回了正确的东西,而不是个null 或者undefined~
我们前端都要这么去处理过滤:
<div class="author"> 文 / {{(musicArticleInfo.author && musicArticleInfo.author.user_id) ? musicArticleInfo.author.user_id.username : '我叫这个名字'}} </div> 复制代码
这就引出了一个思考:
对数据的进一步封装处理,必然渲染性能方面会存在问题,而且我们要时刻担心数据返回的问题。如果应用到公司的业务,我们应该如何处理呢 ?
- 页面性能优化和SEO问题
首屏渲染问题一直是单页应用的痛点,那么除了常用的性能优化,我们还有什么方法优化的吗 ? 这个项目虽然面向的是移动端用户,可能不存在SEO问题,如果做成pc端的话,像文章这类的应用,SEO都是必须品。
对于上面提出的问题,node的出现让我们看到了解决方案,那就常说的 Node中间层 ,当然本项目中是不存在Node中间层,而是直接作为后端语言处理数据库。
由于大部分的公司后端要么是 php 要么是java,一般不把node直接作为后端语言,如果有使用到node,一般是作为一个中间层的形式存在。
对于第一个问题的解决:我们可以在中间层做接口转发,在转发的过程中做数据处理。而不用担心数据返回的问题。
对于第二个问题的解决:有了Node中间层的话,那么我们可以把首屏渲染的任务交给nodejs去做,次屏的渲染依然走之前的浏览器渲染。
有Node中间层的话,新的架构如下:
前后端的职能:
总结
已经毕业一段时间了,写文章是为了回顾。本人水平一般,见谅见谅。这个产品的实现,一个人扛,在其中充当了各种角色,要有一点点产品思维,要有一点点设计的想法,要会数据库设计,要会后端开发,挺繁琐的。最难的点个人感觉还是数据库设计,数据库要一开始就要设计的很完整,不然到后面的添添补补,就会很乱很乱,当然这个基础是产品要非常清晰,刚开始自己心中对产品可能是个模糊的定义,想想差不多是那样,于是乎就开始搞~~导致于后面数据库设计的不是很满意。由于时间关系,现在的产品中有些小模块还没完成,但是大部分的功能结构已经完成,算是个成型的产品,当然是一个没有经过测试的产品哈哈哈哈,要是有测试的话,那就哈哈哈哈你懂得 ~~~。
前路漫漫,吾将上下而求索~
完
谢谢~~
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 毕业设计(1)基于MicroPython的大棚监测控制系统的程序设计与模型设计
- 纳尼,当年的毕业设计竟然收到了侵权
- AI画家第三弹——毕业设计大杀器之Flask
- 花钱请大神写的Java毕业设计案例,反正我的是直接秒过
- 聊聊动态规划(2) -- 特征
- 聊聊动态规划(1) -- 概念
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Python for Everyone
Cay S. Horstmann、Rance D. Necaise / John Wiley & Sons / 2013-4-26 / GBP 181.99
Cay Horstmann's" Python for Everyone "provides readers with step-by-step guidance, a feature that is immensely helpful for building confidence and providing an outline for the task at hand. "Problem S......一起来看看 《Python for Everyone》 这本书的介绍吧!
XML 在线格式化
在线 XML 格式化压缩工具
HEX HSV 转换工具
HEX HSV 互换工具