shiro 권한 제어 (2): 분포 식 구조 에서 shiro 의 실현
shiro 의 기본 적 인 소 개 는 여기 서 그만 두 고 블 로 거들 이 전에 쓴 shiro 튜 토리 얼 을 스스로 뒤 져 볼 수 있 습 니 다. 이 글 은 분포 식 구조 에서 shiro 의 session 공유 문 제 를 설명 합 니 다.
원리 설명
분포 식 이 든 클 러 스 터 에서 든 프로젝트 는 로그 인 사용자 의 정 보 를 얻어 야 한다. 불가능 한 것 은 고객 이 모든 시스템 이나 모든 모듈 에서 반복 적 으로 로그 인 하도록 하 는 것 이다. 클 라 이언 트 가 사용자 정 보 를 서버 에 저장 하도록 하 는 것 도 상식 적 인 문제 이다.
한편, 단일 컴퓨터 모드 에서 저 희 는 shiro 로 로그 인 인증 을 했 습 니 다. 그의 주요 방식 은 첫 번 째 로그 인 할 때 저희 가 설정 한 사용자 정 보 를 cache (메모리) 와 자체 가 가지 고 있 는 ehcahe (캐 시 관리자) 에 저장 한 다음 에 클 라 이언 트 에 게 쿠키 를 주 고 클 라 이언 트 가 방문 할 때마다 쿠키 값 을 가 져 와 사용자 정 보 를 얻 는 것 입 니 다.
자, 그럼 논리 적 으로 알 수 있 습 니 다. 분포 식 구조 에서 다 중 시스템 과 사용자 정 보 를 공유 해 야 합 니 다. 사실은 shiro 가 저장 한 cache 를 공유 하 는 것 입 니 다.
여러 항목 에서 공유 하려 면 메모리 가 불가능 합 니 다. ehcache 는 분포 식 지원 이 좋 지 않 거나 지원 하지 않 습 니 다.그럼 남 은 건 우리 가 아 는 mysql, redis, mongdb 같은 데이터베이스 밖 에 없어 요.이렇게 비교 하면 내 가 말 하지 않 아 도 모두 가 알 게 되 었 다. 가장 적합 한 것 은 바로 redis 이다. 속도 가 빠 르 고 주종 이 무엇 인지.
절차 설명
원본 코드 를 보면 cache Manager 가 최종 적 으로 session DAO 에 설 정 될 것 이라는 것 을 알 수 있 기 때문에 우 리 는 스스로 session DAO 를 써 야 합 니 다.저장 을 조작 하 는 두 가지 종류 가 있 습 니 다. 그러면 우 리 는 다시 쓰 고 이 두 가지 종 류 를 실현 한 다음 에 등록 할 때 설명 하면 됩 니 다.
1. shiro Cache: cache 류 는 정기 적 으로 제거 하 는 MAP 저장 소 를 직접 쓸 수 있 습 니 다. 글 의 끝 에 맵 의 코드 를 드 리 겠 습 니 다.여기 코드 는 redis 에 두 었 습 니 다.
package com.result.shiro.distributed;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import com.result.redis.RedisKey;
import com.result.redis.RedisUtil;
import com.result.tools.KyroUtil;
/**
* @author huangxinyu
* @version :2018 1 8 9:33:23
* cache
*/
@SuppressWarnings("unchecked")
public class ShiroCache implements Cache {
private static final String REDIS_SHIRO_CACHE = RedisKey.CACHEKEY;
private String cacheKey;
private long globExpire = 30;
@SuppressWarnings("rawtypes")
public ShiroCache(String name) {
this.cacheKey = REDIS_SHIRO_CACHE + name + ":";
}
@Override
public V get(K key) throws CacheException {
Object obj = RedisUtil.get(KyroUtil.serialization(getCacheKey(key)));
if(obj==null){
return null;
}
return (V) KyroUtil.deserialization((String)obj);
}
@Override
public V put(K key, V value) throws CacheException {
V old = get(key);
RedisUtil.setex(KyroUtil.serialization(getCacheKey(key)), 18000, KyroUtil.serialization(value));
return old;
}
@Override
public V remove(K key) throws CacheException {
V old = get(key);
RedisUtil.del(KyroUtil.serialization(getCacheKey(key)));
return old;
}
@Override
public void clear() throws CacheException {
for(String key : (Set)keys()){
RedisUtil.del(key);
}
}
@Override
public int size() {
return keys().size();
}
@Override
public Set keys() {
return (Set) RedisUtil.keys(KyroUtil.serialization(getCacheKey("*")));
}
@Override
public Collection values() {
Set set = keys();
List list = new ArrayList<>();
for (K s : set) {
list.add(get(s));
}
return list;
}
private K getCacheKey(Object k) {
return (K) (this.cacheKey + k);
}
}
2. session 작업 클래스: 사용자 정 보 를 redis 에 저장 하여 공유 합 니 다.
package com.result.shiro.distributed;
/**
* @author huangxinyu
* @version :2018 1 6 10:12:42
* redis session
*/
import java.io.Serializable;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.result.redis.RedisKey;
import com.result.redis.RedisUtil;
import com.result.tools.KyroUtil;
import com.result.tools.SerializationUtil;
public class RedisSessionDao extends EnterpriseCacheSessionDAO {
private static Logger logger = LoggerFactory.getLogger(RedisSessionDao.class);
@Override
public void update(Session session) throws UnknownSessionException {
this.saveSession(session);
}
/**
* session
*/
@Override
public void delete(Session session) {
if (session == null || session.getId() == null) {
logger.error("==========session sessionI ");
return;
}
RedisUtil.del(KyroUtil.serialization(RedisKey.SESSIONKEY + session.getId()));
}
/**
* sessions
*/
@Override
public Collection getActiveSessions() {
Set sessions = new HashSet<>();
Set keys = RedisUtil.keys(KyroUtil.serialization(RedisKey.SESSIONKEY + "*"));
for(String key:keys){
sessions.add((Session)KyroUtil.deserialization((String)RedisUtil.get(key)));
}
return sessions;
}
/**
* session
*/
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = this.generateSessionId(session);
this.assignSessionId(session, sessionId);
this.saveSession(session);
return sessionId;
}
/**
* session
*/
@Override
protected Session doReadSession(Serializable sessionId) {
if(sessionId == null){
logger.error("==========session id ");
return null;
}
Object obj = RedisUtil.get(KyroUtil.serialization(RedisKey.SESSIONKEY + sessionId));
if(obj==null){
return null;
}
Session s = (Session)KyroUtil.deserialization((String)obj);
return s;
}
/**
* session
* @param session
* @throws UnknownSessionException
*/
public static void saveSession(String sessionId,Object obj) throws UnknownSessionException{
if (obj == null) {
logger.error(" session ");
return;
}
//
int expireTime = 1800;
RedisUtil.setex(sessionId,expireTime,SerializationUtil.serializeToString(obj));
}
}
package com.result.shiro.distributed;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
/**
* @author huangxinyu
* @version :2018 1 8 9:32:41
*
*/
public class RedisCacheManager implements CacheManager {
@Override
public Cache getCache(String name) throws CacheException {
return new ShiroCache(name);
}
}
3. 보조 류 설명
사용자 정보의 session 을 redis 에 저장 하 는 것 은 분명 직렬 화 되 어야 하지만 json 이라는 가 독성 이 너무 강 한 것 은 안전성 이 매우 낮 고 길이 가 너무 커서 저장 공간 과 IO 를 낭비 합 니 다.그래서 다른 직렬 화 도 구 를 찾 아야 합 니 다.
일반적인 좋 은 서열 화학 공업 은 kyro, protoff 를 가지 고 있 는데 이것 은 성능 이 매우 높 고 서열 화 된 후에 길이 가 매우 작은 서열 화 도구 이다. 그 중에서 protobuf 는 크로스 언어 를 지원 한다.하지만 이 다음 글 들 을 다시 소개 하 러 갑 니 다. 왜냐하면 ~!!session 은 이 두 가지 조작 을 지원 하지 않 습 니 다.
그럼 서열 화 는 무엇 을 사용 합 니까? emmmm ~ 아주 원생 적 인 것 입 니 다. 테스트 효율 도 높 고 protobuf 와 차이 가 많 지 않 습 니 다.아래 에 붙 인 코드 는 실제 적 으로 위 클래스 의 kyroUtils 중의 방법 입 니 다. shiro 분포 식 이 프로젝트 에서 폐기 되 었 기 때문에 저도 이름 을 바 꾸 지 않 았 습 니 다.다 들 잘 보시 면 됩 니 다.
주석 이 달 린 코드 는 kyro 의 직렬 화 도구 입 니 다.
package com.result.tools;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author huangxinyu
* @version :2018 1 6 2:22:14
* Kryo
*/
public class KyroUtil {
private static Logger logger = LoggerFactory.getLogger(KyroUtil.class);
//private static KryoPool pool;
// kyro session, kyro session , value。 out , 4 ,
// static{
// KryoFactory factory = new KryoFactory() {
// public Kryo create() {
// Kryo kryo = new Kryo();
// kryo.setReferences(false);
// // shiroSession Kryo , /
// kryo.register(Session.class, new JavaSerializer());
// kryo.register(String.class, new JavaSerializer());
// kryo.register(User.class, new JavaSerializer());
// kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
// return kryo;
// }
// };
// pool = new KryoPool.Builder(factory).build();
// logger.info("KryoPool ====================================");
// }
/**
*
* @param value
* @return
*/
public static String serialization(Object value) {
// String str ="";
// try {
// Kryo kryo = pool.borrow();
// ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Output output = new Output(baos);
// kryo.writeClassAndObject(output, value);
// output.flush();
// output.close();
// byte[] b = baos.toByteArray();
// baos.flush();
// baos.close();
// str = new String(b, "ISO8859-1");
// } catch (IOException e) {
// e.printStackTrace();
// }
// return str;
//
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
try {
bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(value);
return new String(bos.toByteArray(), "ISO8859-1");
} catch (Exception e) {
throw new RuntimeException("serialize session error", e);
} finally {
try {
oos.close();
bos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
// return new String(new Base64().encode(b));
}
/**
*
* @param
* @param
* @param obj
* @param clazz
* @return
*/
public static Object deserialization(String obj) {
// try {
// Kryo kryo = pool.borrow();
// ByteArrayInputStream bais;
// bais = new ByteArrayInputStream(obj.getBytes("ISO8859-1"));
// //new Base64().decode(obj));
// Input input = new Input(bais);
// return kryo.readClassAndObject(input);
// } catch (UnsupportedEncodingException e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// return null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
bis = new ByteArrayInputStream(obj.getBytes("ISO8859-1"));
ois = new ObjectInputStream(bis);
return ois.readObject();
} catch (Exception e) {
throw new RuntimeException("deserialize session error", e);
} finally {
try {
ois.close();
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
등록
자, 다시 쓸 것 은 모두 다시 썼 습 니 다. 그러면 마지막 단 계 는 spring 을 통합 할 때 우 리 는 spring 에 게 우리 가 다시 쓴 session dao 를 사용 해 야 한다 고 말 해 야 합 니 다.
제 가 사용 하 는 것 은 코드 방식 입 니 다. 어떤 이유 로 프레임 워 크 를 쓸 때 xml 로 통합 하기 가 쉽 지 않 기 때 문 입 니 다.
어차피 원리 가 많 지 않 으 니 모두 가 보면 알 수 있다.
package com.business.shiro;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.session.mgt.ExecutorServiceSessionValidationScheduler;
import org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.config.MethodInvokingFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import com.result.shiro.distributed.RedisCacheManager;
import com.result.shiro.distributed.RedisSessionDao;
/**
* @author huangxinyu
* @version :2018 1 8 8:29:12
*
*/
@Configuration
public class ShiroConfiguration {
private static Map filterChainDefinitionMap = new LinkedHashMap();
@Bean(name = "cacheShiroManager")
public CacheManager getCacheManage() {
return new RedisCacheManager();
}
@Bean(name = "lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean(name = "sessionValidationScheduler")
public ExecutorServiceSessionValidationScheduler getExecutorServiceSessionValidationScheduler() {
ExecutorServiceSessionValidationScheduler scheduler = new ExecutorServiceSessionValidationScheduler();
scheduler.setInterval(900000);
return scheduler;
}
@Bean(name = "hashedCredentialsMatcher")
public HashedCredentialsMatcher getHashedCredentialsMatcher() {
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
credentialsMatcher.setHashAlgorithmName("MD5");
credentialsMatcher.setHashIterations(1);
credentialsMatcher.setStoredCredentialsHexEncoded(true);
return credentialsMatcher;
}
@Bean(name = "sessionIdCookie")
public SimpleCookie getSessionIdCookie() {
SimpleCookie cookie = new SimpleCookie("sid");
cookie.setHttpOnly(true);
cookie.setMaxAge(-1);
return cookie;
}
@Bean(name = "rememberMeCookie")
public SimpleCookie getRememberMeCookie() {
SimpleCookie simpleCookie = new SimpleCookie("rememberMe");
simpleCookie.setHttpOnly(true);
simpleCookie.setMaxAge(2592000);
return simpleCookie;
}
@Bean
public CookieRememberMeManager getRememberManager(){
CookieRememberMeManager meManager = new CookieRememberMeManager();
meManager.setCipherKey(Base64.decode("4AvVhmFLUs0KTA3Kprsdag=="));
meManager.setCookie(getRememberMeCookie());
return meManager;
}
@Bean(name = "sessionManager")
public DefaultWebSessionManager getSessionManage() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setGlobalSessionTimeout(1800000);
sessionManager.setSessionValidationScheduler(getExecutorServiceSessionValidationScheduler());
sessionManager.setSessionValidationSchedulerEnabled(true);
sessionManager.setDeleteInvalidSessions(true);
sessionManager.setSessionIdCookieEnabled(true);
sessionManager.setSessionIdCookie(getSessionIdCookie());
RedisSessionDao cacheSessionDAO = new RedisSessionDao();
cacheSessionDAO.setCacheManager(getCacheManage());
sessionManager.setSessionDAO(cacheSessionDAO);
// ----- session 、
return sessionManager;
}
@Bean(name = "myRealm")
public AuthorizingRealm getShiroRealm() {
MyRealm realm = new MyRealm();
// realm.setName("shiro_auth_cache");
// realm.setAuthenticationCache(getCacheManage().getCache(realm.getName()));
// realm.setAuthenticationTokenClass(UserAuthenticationToken.class);
return realm;
}
@Bean(name = "securityManager")
public DefaultWebSecurityManager getSecurityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setCacheManager(getCacheManage());
securityManager.setSessionManager(getSessionManage());
securityManager.setRememberMeManager(getRememberManager());
securityManager.setRealm(getShiroRealm());
return securityManager;
}
@Bean
public MethodInvokingFactoryBean getMethodInvokingFactoryBean(){
MethodInvokingFactoryBean factoryBean = new MethodInvokingFactoryBean();
factoryBean.setStaticMethod("org.apache.shiro.SecurityUtils.setSecurityManager");
factoryBean.setArguments(new Object[]{getSecurityManager()});
return factoryBean;
}
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator getAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}
@Bean
public AuthorizationAttributeSourceAdvisor getAuthorizationAttributeSourceAdvisor(){
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(getSecurityManager());
return advisor;
}
/**
* @return
*/
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean getShiroFilterFactoryBean(){
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(getSecurityManager());
factoryBean.setLoginUrl("/toLogin");
factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return factoryBean;
}
}
최적화: 의사 시간 에 맵 을 제거 하고 quartz 에 맞 추 는 것 이 좋 습 니 다. 그렇지 않 으 면 메모리 에 있 는 맵 이 접근 하지 않 으 면 제거 되 지 않 고 누적 되 기 쉽 습 니 다.
package com.result.security;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import com.result.NettyGoConstant;
/**
* @author huangxinyu
* @version :2018 1 29 10:31:50
*/
public class ExpiryMap extends HashMap {
private static final long serialVersionUID = 1L;
/**
* default expiry time 2m
*/
private long EXPIRY = NettyGoConstant.LOGINSESSIONTIMEOUT;
private HashMap expiryMap = new HashMap<>();
public ExpiryMap() {
super();
}
public ExpiryMap(long defaultExpiryTime) {
this(1 << 4, defaultExpiryTime);
}
public ExpiryMap(int initialCapacity, long defaultExpiryTime) {
super(initialCapacity);
this.EXPIRY = defaultExpiryTime;
}
public V put(K key, V value) {
expiryMap.put(key, System.currentTimeMillis() + EXPIRY);
return super.put(key, value);
}
public boolean containsKey(Object key) {
return !checkExpiry(key, true) && super.containsKey(key);
}
/**
* @param key
* @param value
* @param expiryTime
*
* @return
*/
public V put(K key, V value, long expiryTime) {
expiryMap.put(key, System.currentTimeMillis() + expiryTime);
return super.put(key, value);
}
public int size() {
return entrySet().size();
}
public boolean isEmpty() {
return entrySet().size() == 0;
}
public boolean containsValue(Object value) {
if (value == null)
return Boolean.FALSE;
Set> set = super.entrySet();
Iterator> iterator = set.iterator();
while (iterator.hasNext()) {
java.util.Map.Entry entry = iterator.next();
if (value.equals(entry.getValue())) {
if (checkExpiry(entry.getKey(), false)) {
iterator.remove();
return Boolean.FALSE;
} else
return Boolean.TRUE;
}
}
return Boolean.FALSE;
}
public Collection values() {
Collection values = super.values();
if (values == null || values.size() < 1)
return values;
Iterator iterator = values.iterator();
while (iterator.hasNext()) {
V next = iterator.next();
if (!containsValue(next))
iterator.remove();
}
return values;
}
public V get(Object key) {
if (key == null)
return null;
if (checkExpiry(key, true))
return null;
return super.get(key);
}
/**
*
* @Description:
* @param key
* @return null: key null -1: value ,
*/
public Object isInvalid(Object key) {
if (key == null)
return null;
if (!expiryMap.containsKey(key)) {
return null;
}
long expiryTime = expiryMap.get(key);
boolean flag = System.currentTimeMillis() > expiryTime;
if (flag) {
super.remove(key);
expiryMap.remove(key);
return -1;
}
return super.get(key);
}
public void putAll(Map extends K, ? extends V> m) {
for (Map.Entry extends K, ? extends V> e : m.entrySet())
expiryMap.put(e.getKey(), System.currentTimeMillis() + EXPIRY);
super.putAll(m);
}
public Set> entrySet() {
Set> set = super.entrySet();
Iterator> iterator = set.iterator();
while (iterator.hasNext()) {
java.util.Map.Entry entry = iterator.next();
if (checkExpiry(entry.getKey(), false))
iterator.remove();
}
return set;
}
/**
*
* @Description:
* @author: qd-ankang
* @date: 2016-11-24 4:05:02
* @param expiryTime
* true
* @param isRemoveSuper
* true super
* @return
*/
private boolean checkExpiry(Object key, boolean isRemoveSuper) {
if (!expiryMap.containsKey(key)) {
return Boolean.FALSE;
}
long expiryTime = expiryMap.get(key);
boolean flag = System.currentTimeMillis() > expiryTime;
if (flag) {
if (isRemoveSuper)
super.remove(key);
expiryMap.remove(key);
}
return flag;
}
/**
*
* @param key
*/
public void del(Object key){
super.remove(key);
expiryMap.remove(key);
}
public static void main(String[] args) throws InterruptedException {
ExpiryMap map = new ExpiryMap<>(10);
map.put("test", "ankang");
map.put("test1", "ankang");
map.put("test2", "ankang", 3000);
System.out.println("test1" + map.get("test"));
Thread.sleep(1000);
System.out.println("isInvalid:" + map.isInvalid("test"));
System.out.println("size:" + map.size());
System.out.println("size:" + ((HashMap) map).size());
for (Map.Entry m : map.entrySet()) {
System.out.println("isInvalid:" + map.isInvalid(m.getKey()));
map.containsKey(m.getKey());
System.out.println("key:" + m.getKey() + " value:" + m.getValue());
}
System.out.println("test1" + map.get("test"));
}
/**
*
* @param key
* @return
*/
public boolean isHalfExpiryTime(Object key ){
if (!expiryMap.containsKey(key)) {
return false;
}
long expiryTime = expiryMap.get(key);
boolean flag = System.currentTimeMillis()-(expiryTime-NettyGoConstant.LOGINSESSIONTIMEOUT)>=NettyGoConstant.LOGINSESSIONTIMEOUT/2;
return flag;
}
}
이 내용에 흥미가 있습니까?
현재 기사가 여러분의 문제를 해결하지 못하는 경우 AI 엔진은 머신러닝 분석(스마트 모델이 방금 만들어져 부정확한 경우가 있을 수 있음)을 통해 가장 유사한 기사를 추천합니다:
다양한 언어의 JSONJSON은 Javascript 표기법을 사용하여 데이터 구조를 레이아웃하는 데이터 형식입니다. 그러나 Javascript가 코드에서 이러한 구조를 나타낼 수 있는 유일한 언어는 아닙니다. 저는 일반적으로 '객체'{}...
텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
CC BY-SA 2.5, CC BY-SA 3.0 및 CC BY-SA 4.0에 따라 라이센스가 부여됩니다.