در چند سال اخیر، تمرکز اولیه من بر روی معماری نرمافزار و توسعهدهی سرویسهای backend بوده است. من سعی کردهام تا جایی که ممکن است، از frontend دور بمانم؛ زیرا frontend تنها عصر توسعهدهی نرمافزار است که من در آن احساس بیهودگی میکنم.
پس با آرزوی بهتر کردن مهارتهای خود، تصمیم گرفتم که کمی به توسعهدهی frontend روی بیاورم و ببینم که چه میشود. چند سال پیش کمی با Angular کار کرده بودم. پس تصمیم گرفتم که از آخرین نسخه آن استفاده کنم؛ اما از آنجایی که محبوبیت Vue همینطور حال رشد است، ما اخیرا شروع به استفاده از آن در شرکت خود کردیم و من هم تصمیم گرفتم که آن را امتحان کنم. همچنین با توجه به این که من سعی کردم این پست را به نوعی در قالب یک آموزش ساختاربندی کنم، برخی قطعه کدهای طولانی را هم در اینجا مشاهده خواهید کرد.
عموما من وقتی که در حال شروع به کار در یک فریموورک یا زبان جدید هستم، سعی میکنم در حد ممکن به دنبال بهترین روشها باشم؛ زیرا من ترجیح میدهم با یک ساختار خوب شروع کنم، که در آینده میتواند به سادگی درک، نگهداری و بروزرسانی شود. در این پست سعی خواهم کرد که نحوه تفکر خود را توضیح دهم، و تمام دانشی که در چند سال اخیر به دست آوردهام را با آخرین و بهترین روشهای توسعهدهی وب ترکیب کنم.
ما به همراه یکدیگر یک پروژه ساده را خواهیم ساخت که یک روند احراز هویت را مدیریت میکند و یک چارچوب پایه را آماده خواهیم کرد، تا در هنگام ساخت باقی برنامه از آن استفاده کنیم.
ما از این موارد استفاده خواهیم کرد:
- Vue.js 2.5 و Vue-CLI
- Vuex 3.0
- Axios 0.18
- Vue Router 3.0
در اینجا ساختار نهایی پروژه را مشاهده مینمایید، که وقتی همه کار ما تمام شد به آن خواهیم رسید. من فرض میکنم که شما درباره Vue، Vuex و Vue Router مطالبی را خواندهاید و اساس پشت آنها را درک میکنید. اگر هم نخواندهاید، نترسید. من همه چیز را ساده نگه خواهم داشت. فقط انتظار نداشته باشید که از این پست چیزی در آن زمینهها یاد بگیرید.
└── src
├── App.vue
├── assets
│ └── logo.png
├── components
│ └── HelloWorld.vue
├── main.js
├── router.js
├── services
│ ├── api.service.js
│ ├── storage.service.js
│ └── user.service.js
├── store
│ ├── auth.module.js
│ └── index.js
└── views
├── About.vue
├── Home.vue
└── LoginView.vue
صفحات محافظت شده
در ابتدا بیایید از برخی URLها محافظت کنیم، تا فقط برای کاربران وارد شده در دسترس باشند. برای انجام این کار، ما نیاز خواهیم اشت که فایل router.js را ویرایش کنیم. من رویکردی را در پیش گرفتهام که در آن تمام صفحات خصوصی هستند، به جز صفحاتی که ما مستقیما به عنوان «عمومی» نشانهگذاری میکنیم؛ زیرا به نظر من بهتر است که پدیداری آنها را به صورت پیشفرض بر روی خصوصی قرار دهید و اگر میخواهید مسیرهایی که میخواهید در دسترس باشند را در معرض کاربران قرار دهید، این کار را به صراحت انجام دهید.
در کد زیر، ما از عملکرد meta در Vue Router استفاده میکنیم. ما همچنین کاربران خود را پس از این که وارد شدند، به صفحهای منتقل خواهیم کرد که میخواستند مشاهده نمایند. ما میخواهیم view وارد شدن (log in) فقط وقتی که یک کاربر از قبل وارد نشده است، در دسترس باشد؛ پس ما یک flag دیگر به نام onlyWhenLoggedOut در فیلد meta اضافه کردهایم.
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import LoginView from './views/LoginView.vue'
import { TokenService } from './services/storage.service'
Vue.use(Router)
const router = new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/login',
name: 'login',
component: LoginView,
meta: {
public: true, // حتی اگر کاربر وارد نشده است هم اجازه دسترسی را بده
onlyWhenLoggedOut: true
}
},
{
path: '/about',
name: 'about',
// تقسیم کد در سطح مسیر
// این کد یک قطعه دیگر برای این مسیر میسازد
// که وقتی مسیر مورد نظر بازدید میشود، بارگذاری میشود.
component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
},
]
})
router.beforeEach((to, from, next) => {
const isPublic = to.matched.some(record => record.meta.public)
const onlyWhenLoggedOut = to.matched.some(record => record.meta.onlyWhenLoggedOut)
const loggedIn = !!TokenService.getToken();
if (!isPublic && !loggedIn) {
return next({
path:'/login',
query: {redirect: to.fullPath} // Store the full path to redirect the user to after login
});
}
// اگر کاربر وارد شده است، به او اجازه نده که صفحه ورود را مشاهده کرده، و یا ثبت نام کند
if (loggedIn && onlyWhenLoggedOut) {
return next('/')
}
next();
})
export default router;
در اینجا متوجه خواهید شد که ما در حال وارد کردن یک TokenServce هستیم، که یک نشانه را بر میگرداند. TokenService در شاخه services/storage.service.js قرار دارد و تنها کاری که انجام میدهد، این است که منطق مربوط به مدیریت مخزن و بازیابی نشانه دسترسی به localStorage و از آن را کپسولهسازی کند.
به این صورت میتوانیم به صورت امن از local storage به کوکیها مهاجرت کنیم، بدون این که نگران قطع کردن یک سرویس یا کامپوننت دیگر که مستقیما به آن دسترسی دارد باشیم. به نظر من این روش برای جلوگیری از دردسرهای آینده، یک روش خوب است. کد موجود در فایل storage.service.js چنین ظاهری دارد:
const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
/**
* نحوه ذخیرهسازی و بازیابی نشانههای دسترسی از مخزن را مدیریت کن
*
* پیادهسازی فعلی در حافظه محلی ذخیره میکند. باید همیشه از طریق این نمونه به حافظه محلی دسترسی ایجاد شود.
**/
const TokenService = {
getToken() {
return localStorage.getItem(TOKEN_KEY)
},
saveToken(accessToken) {
localStorage.setItem(TOKEN_KEY, accessToken)
},
removeToken() {
localStorage.removeItem(TOKEN_KEY)
},
getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY)
},
saveRefreshToken(refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
},
removeRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
}
export { TokenService }
ارسال درخواستهای API
وقتی که به تعامل با API میرسیم، ما میتوانیم از منطقی مشابه به منطق موجود در TokenService استفاده کنیم. ما یک سرویس پایه را میسازیم، که تمام تعاملات با شبکه را انجام میدهد. به این صورت ما میتوانیم در آینده همه چیز را به راحتی تغییر داده، یا بروزرسانی کنیم. این دقیقا چیزی است که ما در تلاشیم با استفاده از api.service.js به دست بیاوریم. کتابخانه Axios را کپسولهسازی کنیم، تا وقتی که یک چیز جدید به ناچار به میان میآید، ما بتوانیم به این سرویس باز گردیم و بدون نیاز به بازسازی کل برنامه، آن را بروزرسانی کنیم. هر سرویس دیگری که باید با API در تعامل باشد، به سادگی ApiService را به خود وارد (import) خواهد کرد و درخواستها را از طریق متدهایی که ما پیادهسازی کردهایم، ارسال خواهد کرد.
import axios from 'axios'
import { TokenService } from '../services/storage.service'
const ApiService = {
init(baseURL) {
axios.defaults.baseURL = baseURL;
},
setHeader() {
axios.defaults.headers.common["Authorization"] = `Bearer ${TokenService.getToken()}`
},
removeHeader() {
axios.defaults.headers.common = {}
},
get(resource) {
return axios.get(resource)
},
post(resource, data) {
return axios.post(resource, data)
},
put(resource, data) {
return axios.put(resource, data)
},
delete(resource) {
return axios.delete(resource)
},
/**
* Perform a custom Axios request.
*
* داده، یک آبجکت است که شامل این ویژگیها میباشد:
* - متد
* - url
* - data ... request payload
* - احراز هویت (اختیاری)
* - نام کاربری
* - رمز عبور
**/
customRequest(data) {
return axios(data)
}
}
export default ApiService
شاید متوجه شده باشید که در آنجا یک تابع init (راهاندازی) و setHeader (تنظیم header) وجود دارند. ما ApiService را در داخل فایل main.js راهاندازی (init) خواهیم کرد، تا مطمئن شویم که اگر کاربر صفحه را مجددا بارگذاری کرد، header و همچنین ویژگی baseURL را نیز تنظیم کنیم.
برای این که به صورت دینامیک URL را در محیطهای توسعهدهی، استقرار و تولید تغییر دهیم، من از متغیرهای محیطی Vue CLI استفاده میکنم.
در داخل فایل main.js خود، importهای مناسب و سپس این خطوط را قرار دهید:
// Set the base URL of the API
ApiService.init(process.env.VUE_APP_ROOT_API)
// اگر نشانه وجود دارد، هِدِر را تنظیم کن
if (TokenService.getToken()) {
ApiService.setHeader()
}
خب، پس حال ما میدانیم که چگونه باید کاربر را به یک صفحه ورود منتقل کنیم و یک کد قالب پایه درست کردهایم، که به ما در نگه داشتن یک پروژه، به صورت مرتب و قابل نگهداری کمک خواهد کرد. بیایید شروع به کار بر روی فایل user.service.js نماییم، تا بتوانیم یک درخواست را ارسال کنیم و ببینیم که چگونه باید از ApiService که پیشتر آن را ساختیم، استفاده کنیم.
import ApiService from './api.service'
import { TokenService } from './storage.service'
class AuthenticationError extends Error {
constructor(errorCode, message) {
super(message)
this.name = this.constructor.name
this.message = message
this.errorCode = errorCode
}
}
const UserService = {
/**
* Login the user and store the access token to TokenService.
*
* @returns access_token
* @throws AuthenticationError
**/
login: async function(email, password) {
const requestData = {
method: 'post',
url: "/o/token/",
data: {
grant_type: 'password',
username: email,
password: password
},
auth: {
username: process.env.VUE_APP_CLIENT_ID,
password: process.env.VUE_APP_CLIENT_SECRET
}
}
try {
const response = await ApiService.customRequest(requestData)
TokenService.saveToken(response.data.access_token)
TokenService.saveRefreshToken(response.data.refresh_token)
ApiService.setHeader()
// NOTE: We haven't covered this yet in our ApiService
// but don't worry about this just yet - I'll come back to it later
ApiService.mount401Interceptor();
return response.data.access_token
} catch (error) {
throw new AuthenticationError(error.response.status, error.response.data.detail)
}
},
/**
* بارگذاری مجدد نشانه دسترسی
**/
refreshToken: async function() {
const refreshToken = TokenService.getRefreshToken()
const requestData = {
method: 'post',
url: "/o/token/",
data: {
grant_type: 'refresh_token',
refresh_token: refreshToken
},
auth: {
username: process.env.VUE_APP_CLIENT_ID,
password: process.env.VUE_APP_CLIENT_SECRET
}
}
try {
const response = await ApiService.customRequest(requestData)
TokenService.saveToken(response.data.access_token)
TokenService.saveRefreshToken(response.data.refresh_token)
// Update the header in ApiService
ApiService.setHeader()
return response.data.access_token
} catch (error) {
throw new AuthenticationError(error.response.status, error.response.data.detail)
}
},
/**
* با حذف کردن نشانه از مخزن، کاربر فعلی را خارج کن.
*
* Will also remove `Authorization Bearer <token>` header from future requests.
**/
logout() {
// Remove the token and remove Authorization header from Api Service as well
TokenService.removeToken()
TokenService.removeRefreshToken()
ApiService.removeHeader()
// نکته: در ادامه رهگیر ۴۰۱ را پوشش خواهیم داد
ApiService.unmount401Interceptor()
}
}
export default UserService
export { UserService, AuthenticationError }
ما در حال پیادهسازی یک UserService هستیم که سه متد در خود دارد:
- login - یک درخواست را آماده کن و یک نشاده از API، از طریق سرویس API دریافت کن.
- logout - موارد مربوط به کاربر را از مخزن مرورگر پاک کن.
- refresh token - یک نشانه بارگذاری مجدد از سرویس API به دست بیاور.
اگر دقت کرده باشید، متوجه خواهید شد که یک منطق رهگیری 401 مرموز در اینجا وجود دارد. در ادامه آن را پوشش خواهیم داد. این کد فقط به این دلیل در آنجا قرار دارد که مجبور نشوم برای این که به شما نشان دهم آن را در کجا قرار دهیم، یک قطعه کد دیگر را نیز شامل کنم.
آیا باید این منطق را در مخزن Vuex قرار دهم، یا در کامپوننت؟
این که در حد ممکن منطق خود را در داخل مخزن Vuex قرار دهید، یک روش خوب به نظر میرسد. عموما این کار خوب است؛ زیرا شما میتوانید از state و منطق خود در کامپوننتهای مختلف مجددا استفاده کنید.
برای مثال فرض کنید که برنامه شما کاربران را قادر میسازد تا وارد شوند، یا در چند جای مختلف ثبت نام کنند. (در یک صفحه ورود / ثبت نام اختصاصی، یا در یک فروشگاه آنلاین که آنها سبد خرید خود را از طریق یک پنجره popup چک میکنند) احتمالا شما از یک کامپوننت Vue متفاوت برای آن عنصر رابط کاربری استفاده خواهید کرد. شما با قرار دادن state و منطق خود در مخزن Vuex، خواهید توانست که state و منطق مورد نظر مجددا استفاده کنید و در کامپوننت خود، به سادگی چند بیانیه import کوتاه اضافه کنید:
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "login",
data() {
return {
email: "",
password: "",
};
},
computed: {
...mapGetters('auth', [
'authenticating',
'authenticationError',
'authenticationErrorCode'
])
},
methods: {
...mapActions('auth', [
'login'
]),
handleSubmit() {
// یک اعتبارسنجی ساده انجام بده که ایمیل و رمز عبور تایپ شده باشند
if (this.email != '' && this.password != '') {
this.login({email: this.email, password: this.password})
this.password = ""
}
}
}
};
</script>
شما در کامپوننت Vue خود، منطق را در مخزن Vuex وارد خواهید کرد و state یا getterها را به مقادیر محاسبه شده خود map خواهید کرد، و همچنین actionها را هم به متدهای خود map خواهید کرد.
پس کد مخزن Vuex ما برای فایل user.service.js به چه صورت است؟
import { UserService, AuthenticationError } from '../services/user.service'
import { TokenService } from '../services/storage.service'
import router from '../router'
const state = {
authenticating: false,
accessToken: TokenService.getToken(),
authenticationErrorCode: 0,
authenticationError: ''
}
const getters = {
loggedIn: (state) => {
return state.accessToken ? true : false
},
authenticationErrorCode: (state) => {
return state.authenticationErrorCode
},
authenticationError: (state) => {
return state.authenticationError
},
authenticating: (state) => {
return state.authenticating
}
}
const actions = {
async login({ commit }, {email, password}) {
commit('loginRequest');
try {
const token = await UserService.login(email, password);
commit('loginSuccess', token)
// کاربر را به صفحهای که میخواست ببیند منتقل کن
router.push(router.history.current.query.redirect || '/');
return true
} catch (e) {
if (e instanceof AuthenticationError) {
commit('loginError', {errorCode: e.errorCode, errorMessage: e.message})
}
return false
}
},
logout({ commit }) {
UserService.logout()
commit('logoutSuccess')
router.push('/login')
}
}
const mutations = {
loginRequest(state) {
state.authenticating = true;
state.authenticationError = ''
state.authenticationErrorCode = 0
},
loginSuccess(state, accessToken) {
state.accessToken = accessToken
state.authenticating = false;
},
loginError(state, {errorCode, errorMessage}) {
state.authenticating = false
state.authenticationErrorCode = errorCode
state.authenticationError = errorMessage
},
logoutSuccess(state) {
state.accessToken = ''
}
}
export const auth = {
namespaced: true,
state,
getters,
actions,
mutations
}
این کد تقریبا هر چیزی که برای راهاندازی پروژه خود نیاز دارید را پوشش میدهد؛ به گونهای که میتوانید همه چیز را به صورت مرتب و قابل نگهداری در دست داشته باشید.
حال دریافت دادههای بیشتر از API باید ساده باشد. فقط به سادگی یک <something>.service.js در داخل سرویسها اضافه کنید، متدهای کمکی را بنویسید و از طریق ApiService که پیشتر ساختیم، به API مورد نظر دسترسی داشته باشید. برای نمایش این دادهها، یک مخزن Vuex بسازید و پاسخهای API را در state قرار دهید. از طریق mapState و mapActions از آن در کامپوننتهای خود استفاده کنید. به این صورت اگر نیاز باشد که دادههای مشابه را در کامپوننتهای مختلف نمایش داده، یا دستکاری کنید، خواهید توانست که از منطق مورد نظر در آینده مجددا استفاده کنید.
من یک متخصص Vue نیستم، اما فکر میکنم که تا حدودی درباره معماری نرمافزار بلدم و امیدوارم که این پست برخی ایدهها و مفاهیم کاربردی را در خود داشته باشد که بتوانید در پروژههای بعدی خود از آنها استفاده کنید.
به علاوه: چگونه نشانههای دسترسی منقضی شده را تازه کنیم؟
یک مورد که کمی سختتر است و در بسیاری از آموزشها وقتی که به احراز هویت میرسد، نادیده گرفته میشود، مدیریت بارگذاریهای مجدد نشانه یا خطاهای 401 است. برخی موقعیتها هستند که وقتی یک خطای 401 بروز میدهد، خوب است که کاربر را خارج (logout) کنید، اما بیایید ببینیم که چگونه میتوانیم نشانه دسترسی را بدون ایجاد اختلال در تجربه کاربری، مجددا بارگذاری کنیم. در اینجا رهگیر 401 که در نمونه کدهای بالا داشتیم را مشاهده مینماییم، که پیشتر به آن اشاره شد.
ما در ApiService خود، کد زیر را اضافه میکنیم تا رهگیر پاسخ Axios را سوار کنیم:
...
import { store } from '../store'
const ApiService = {
// موقعیت رهگیر ۴۰۱ را ذخیره میکند تا بعدا و در صورت نیاز، آن را خارج کند
_401interceptor: null,
...
mount401Interceptor() {
this._401interceptor = axios.interceptors.response.use(
(response) => {
return response
},
async (error) => {
if (error.request.status == 401) {
if (error.config.url.includes('/o/token/')) {
// بارگذاری مجدد نشانه با شکست مواجه شد. کاربر را خارج کن.
store.dispatch('auth/logout')
throw error
} else {
// نشانه دسترسی را مجددا بارگذاری کن
try{
await store.dispatch('auth/refreshToken')
// درخواست اصلی را مجددا آزمایش کن
return this.customRequest({
method: error.config.method,
url: error.config.url,
data: error.config.data
})
} catch (e) {
// بارگذاری مجدد با شکست مواجه شده است - درخواست اصلی را رد کن - یک خطا را نمایش بده }
}
}
// اگر خطای مورد نظر ۴۰۱ نبود، فقط خارج کن - یک خطا را نمایش بده
}
)
},
unmount401Interceptor() {
// رهگیر را خارج کن
axios.interceptors.response.eject(this._401interceptor)
}
}
کاری که کد بالا انجام میدهد، این است که تمام پاسخهای API را رهگیری کرده و بررسی کند که آیا وضعیت پاسخ مورد نظر، 401 است یا نه. اگر هست، بررسی میکنیم تا ببینیم که آیا خطای 401 بر روی خود فراخونی بارگذاری مجدد نشانه بروز داد، یا نه. (زیرا ما نمیخواهیم که برای همیشه در حلقه بارگذاری مجدد نشانه گیر کنیم) سپس این کد نشانه را مجددا بارگذاری میکند و درخواستی که با شکست مواجه شده است را باز هم امتحان میکند، و سپس هم پاسخ را به فراخوان بر میگرداند.
ما در حال اعزام یک فراخوانی به مخزن Vuex هستیم، تا بارگذاری مجدد نشانه را اجرا کنیم. کدی که باید به فایل auth.module.js اضافه کنیم، این کد است:
const state = {
...
refreshTokenPromise: null // Holds the promise of the refresh token
}
const actions = {
...
refreshToken({ commit, state }) {
// If this is the first time the refreshToken has been called, make a request
// otherwise return the same promise to the caller
if(!state.refreshTokenPromise) {
const p = UserService.refreshToken()
commit('refreshTokenPromise', p)
// Wait for the UserService.refreshToken() to resolve. On success set the token and clear promise
// Clear the promise on error as well.
p.then(
response => {
commit('refreshTokenPromise', null)
commit('loginSuccess', response)
},
error => {
commit('refreshTokenPromise', null)
}
)
}
return state.refreshTokenPromise
}
}
const mutations = {
...
refreshTokenPromise(state, promise) {
state.refreshTokenPromise = promise
}
}
احتمالا برنامه شما چندین درخواست API را اجرا خواهد کرد، تا دادههایی که نیاز دارد نمایش دهد را به دست بیاورد. اگر نشانه دسترسی منقضی شود، تمام درخواستها شکست خواهند خورد و از این رو بارگذاری مجدد نشانه را در داخل رهگیر 401، مجددا بارگذاری خواهند کرد. این اتفاق به عبارتی نشانه مربوط به هر درخواست را مجددا بارگذاری خواهد کرد و این اتفاقی خوبی نیست.
راه حلهایی وجود دارند که وقتی رهگیر 401 پیش میآید، درخواستها را در صف قرار میدهند و آنها را در صف پردازش میکنند، اما کد بالا حداقل برای من یک راه حل زیباتر را فراهم میکند. ما با بارگذاری مجدد promise نشانه و برگرداندن promise مشابه به هر درخواست نشانه بارگذاری مجدد، تضمین میکنیم که نشانه مورد نظر فقط یک بار مجددا بارگذاری میشود.
همچنین ما نیاز خواهیم داشت که رهگیر 401 را هم دقیقا پس از تنظیم header درخواست، در فایل main.js سوار کنیم.
دیدگاه و پرسش
در حال دریافت نظرات از سرور، لطفا منتظر بمانید
در حال دریافت نظرات از سرور، لطفا منتظر بمانید